diff --git a/.freeCodeCamp/client/assets/fcc_primary_large.tsx b/.freeCodeCamp/client/assets/fcc_primary_large.tsx deleted file mode 100644 index 1e1a28f8..00000000 --- a/.freeCodeCamp/client/assets/fcc_primary_large.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from 'react'; - -function FreeCodeCampLogo( - props: JSX.IntrinsicAttributes & React.SVGProps -): JSX.Element { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} - -FreeCodeCampLogo.displayName = 'FreeCodeCampLogo'; - -export default FreeCodeCampLogo; diff --git a/.freeCodeCamp/client/components/console.tsx b/.freeCodeCamp/client/components/console.tsx deleted file mode 100644 index 8c72a318..00000000 --- a/.freeCodeCamp/client/components/console.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { ConsoleError } from '../types'; - -export const Console = ({ cons }: { cons: ConsoleError[] }) => { - return ( - - ); -}; - -const ConsoleElement = ({ testText, testId, error }: ConsoleError) => { - const details = `${testId + 1} ${testText} - - ${error}`; - return ( -
- ); -}; diff --git a/.freeCodeCamp/client/components/loader.tsx b/.freeCodeCamp/client/components/loader.tsx deleted file mode 100644 index 2b7bfe3f..00000000 --- a/.freeCodeCamp/client/components/loader.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export const Loader = ({ size = '100' }: { size?: string }) => { - return
; -}; diff --git a/.freeCodeCamp/client/components/test.tsx b/.freeCodeCamp/client/components/test.tsx deleted file mode 100644 index b8b3ecf9..00000000 --- a/.freeCodeCamp/client/components/test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Loader } from './loader'; -import { TestType } from '../types'; - -export const Test = ({ testText, passed, isLoading, testId }: TestType) => { - return ( -
  • - - {testId + 1}) {isLoading ? : passed ? '✓' : '✗'}{' '} - -
    -
  • - ); -}; diff --git a/.freeCodeCamp/client/types/index.ts b/.freeCodeCamp/client/types/index.ts deleted file mode 100644 index 2ad7728a..00000000 --- a/.freeCodeCamp/client/types/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -export type F = (arg: A) => R; - -export enum Events { - CONNECT = 'connect', - DISCONNECT = 'disconnect', - TOGGLE_LOADER_ANIMATION = 'toggle-loader-animation', - UPDATE_TESTS = 'update-tests', - UPDATE_TEST = 'update-test', - UPDATE_DESCRIPTION = 'update-description', - UPDATE_PROJECT_HEADING = 'update-project-heading', - UPDATE_PROJECTS = 'update-projects', - RESET_TESTS = 'reset-tests', - RUN_TESTS = 'run-tests', - RESET_PROJECT = 'reset-project', - REQUEST_DATA = 'request-data', - GO_TO_NEXT_LESSON = 'go-to-next-lesson', - GO_TO_PREVIOUS_LESSON = 'go-to-previous-lesson', - SELECT_PROJECT = 'select-project', - CANCEL_TESTS = 'cancel-tests', - CHANGE_LANGUAGE = 'change-language' -} - -export type TestType = { - testText: string; - passed: boolean; - isLoading: boolean; - testId: number; -}; - -export type LoaderT = { - isLoading: boolean; - progress: { - total: number; - count: number; - }; -}; - -export interface ProjectI { - id: number; - title: string; - description: string; - isIntegrated: boolean; - isPublic: boolean; - currentLesson: number; - numberOfLessons: number; - isResetEnabled?: boolean; - completedDate: null | number; - tags: string[]; -} - -export type ConsoleError = { - error: string; -} & TestType; - -export type FreeCodeCampConfigI = { - [key: string]: any; -}; diff --git a/.freeCodeCamp/client/utils/index.ts b/.freeCodeCamp/client/utils/index.ts deleted file mode 100644 index e0032f49..00000000 --- a/.freeCodeCamp/client/utils/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { marked } from 'marked'; -import { markedHighlight } from 'marked-highlight'; -import Prism from 'prismjs'; - -marked.use( - markedHighlight({ - highlight: (code, lang: keyof (typeof Prism)['languages']) => { - if (Prism.languages[lang]) { - return Prism.highlight(code, Prism.languages[lang], String(lang)); - } else { - return code; - } - } - }) -); - -function parseMarkdown(markdown: string) { - return marked.parse(markdown, { gfm: true }); -} - -export function parse(objOrString: any) { - if (typeof objOrString === 'string') { - return JSON.parse(objOrString); - } else { - return JSON.stringify(objOrString); - } -} diff --git a/.freeCodeCamp/plugin/index.js b/.freeCodeCamp/plugin/index.js deleted file mode 100644 index 4f08ad30..00000000 --- a/.freeCodeCamp/plugin/index.js +++ /dev/null @@ -1,162 +0,0 @@ -import { readFile } from 'fs/promises'; -import { freeCodeCampConfig, getState, ROOT } from '../tooling/env.js'; -import { CoffeeDown, parseMarkdown } from '../tooling/parser.js'; -import { join } from 'path'; -import { logover } from '../tooling/logger.js'; - -/** - * Project config from `config/projects.json` - * @typedef {Object} Project - * @property {string} id - * @property {string} title - * @property {string} dashedName - * @property {string} description - * @property {boolean} isIntegrated - * @property {boolean} isPublic - * @property {number} currentLesson - * @property {boolean} runTestsOnWatch - * @property {boolean} isResetEnabled - * @property {number} numberOfLessons - * @property {boolean} seedEveryLesson - * @property {boolean} blockingTests - * @property {boolean} breakOnFailure - */ - -/** - * @typedef {Object} TestsState - * @property {boolean} passed - * @property {string} testText - * @property {number} testId - * @property {boolean} isLoading - */ - -/** - * @typedef {Object} Lesson - * @property {{watch?: string[]; ignore?: string[]} | undefined} meta - * @property {string} description - * @property {[[string, string]]} tests - * @property {string[]} hints - * @property {[{filePath: string; fileSeed: string} | string]} seed - * @property {boolean?} isForce - * @property {string?} beforeAll - * @property {string?} afterAll - * @property {string?} beforeEach - * @property {string?} afterEach - */ - -export const pluginEvents = { - /** - * @param {Project} project - * @param {TestsState[]} testsState - */ - onTestsStart: async (project, testsState) => {}, - - /** - * @param {Project} project - * @param {TestsState[]} testsState - */ - onTestsEnd: async (project, testsState) => {}, - - /** - * @param {Project} project - */ - onProjectStart: async project => {}, - - /** - * @param {Project} project - */ - onProjectFinished: async project => {}, - - /** - * @param {Project} project - */ - onLessonPassed: async project => {}, - - /** - * @param {Project} project - */ - onLessonFailed: async project => {}, - - /** - * @param {Project} project - */ - onLessonLoad: async project => {}, - - /** - * @param {string} projectDashedName - * @returns {Promise<{title: string; description: string; numberOfLessons: number; tags: string[]}>} - */ - getProjectMeta: async projectDashedName => { - const { locale } = await getState(); - const projectFilePath = join( - ROOT, - freeCodeCampConfig.curriculum.locales[locale], - projectDashedName + '.md' - ); - const projectFile = await readFile(projectFilePath, 'utf8'); - const coffeeDown = new CoffeeDown(projectFile); - const projectMeta = coffeeDown.getProjectMeta(); - // Remove `

    ` tags if present - const title = parseMarkdown(projectMeta.title) - .replace(/

    |<\/p>/g, '') - .trim(); - const description = parseMarkdown(projectMeta.description).trim(); - const tags = projectMeta.tags; - const numberOfLessons = projectMeta.numberOfLessons; - return { title, description, numberOfLessons, tags }; - }, - - /** - * @param {string} projectDashedName - * @param {number} lessonNumber - * @returns {Promise} lesson - */ - getLesson: async (projectDashedName, lessonNumber) => { - const { locale } = await getState(); - const projectFilePath = join( - ROOT, - freeCodeCampConfig.curriculum.locales[locale], - projectDashedName + '.md' - ); - const projectFile = await readFile(projectFilePath, 'utf8'); - const coffeeDown = new CoffeeDown(projectFile); - const lesson = coffeeDown.getLesson(lessonNumber); - let seed = lesson.seed; - if (!seed.length) { - // Check for external seed file - const seedFilePath = projectFilePath.replace(/.md$/, '-seed.md'); - try { - const seedContent = await readFile(seedFilePath, 'utf-8'); - const coffeeDown = new CoffeeDown(seedContent); - seed = coffeeDown.getLesson(lessonNumber).seed; - } catch (e) { - if (e?.code !== 'ENOENT') { - logover.debug(e); - throw new Error( - `Error reading external seed for lesson ${lessonNumber}` - ); - } - } - } - const { afterAll, afterEach, beforeAll, beforeEach, isForce, meta } = - lesson; - const description = parseMarkdown(lesson.description).trim(); - const tests = lesson.tests.map(([testText, test]) => [ - parseMarkdown(testText).trim(), - test - ]); - const hints = lesson.hints.map(h => parseMarkdown(h).trim()); - return { - meta, - description, - tests, - hints, - seed, - beforeAll, - afterAll, - beforeEach, - afterEach, - isForce - }; - } -}; diff --git a/.freeCodeCamp/tests/parser.test.js b/.freeCodeCamp/tests/parser.test.js deleted file mode 100644 index e674465b..00000000 --- a/.freeCodeCamp/tests/parser.test.js +++ /dev/null @@ -1,122 +0,0 @@ -/// Tests can be run from `self/` -/// node ../.freeCodeCamp/tests/parser.test.js -import { assert } from 'chai'; -import { Logger } from 'logover'; -import { pluginEvents } from '../plugin/index.js'; - -const logover = new Logger({ - debug: '\x1b[33m[parser.test]\x1b[0m', - error: '\x1b[31m[parser.test]\x1b[0m', - level: 'debug', - timestamp: null -}); - -try { - const { - title, - description: projectDescription, - numberOfLessons - } = await pluginEvents.getProjectMeta('build-x-using-y'); - const { - meta, - description: lessonDescription, - tests, - hints, - seed, - isForce, - beforeAll, - beforeEach, - afterAll, - afterEach - } = await pluginEvents.getLesson('build-x-using-y', 0); - - assert.deepEqual(title, 'Build X Using Y'); - assert.deepEqual(meta, { - watch: ['some/file.js'], - ignore: ['another/file.js'] - }); - assert.deepEqual( - projectDescription, - '

    In this course, you will build x using y.

    ' - ); - assert.deepEqual(numberOfLessons, 1); - - assert.deepEqual( - lessonDescription, - `

    Some description here.

    -
    fn main() {
    -    println!("Hello, world!");
    -}
    -

    Here is an image:

    -` - ); - - const expectedTests = [ - [ - '

    First test using Chai.js assert.

    ', - '// 0\n// Timeout for 3 seconds\nawait new Promise(resolve => setTimeout(resolve, 3000));\nassert.equal(true, true);' - ], - [ - '

    Second test using global variables passed from before hook.

    ', - "// 1\nawait new Promise(resolve => setTimeout(resolve, 4000));\nassert.equal(__projectLoc, 'example global variable for tests');" - ], - [ - '

    Dynamic helpers should be imported.

    ', - "// 2\nawait new Promise(resolve => setTimeout(resolve, 1000));\nassert.equal(__helpers.testDynamicHelper(), 'Helper success!');" - ] - ]; - - for (const [i, [testText, testCode]] of tests.entries()) { - assert.deepEqual(testText, expectedTests[i][0]); - assert.deepEqual(testCode, expectedTests[i][1]); - } - - const expectedHints = [ - '

    Inline hint with some code blocks.

    ', - `

    Multi-line hint with:

    -
    const code_block = true;
    -
    ` - ]; - - for (const [i, hint] of hints.entries()) { - assert.deepEqual(hint, expectedHints[i]); - } - - const expectedSeed = [ - { - filePath: 'build-x-using-y/readme.md', - fileSeed: '# Build X Using Y\n\nIn this course\n\n## 0\n\nHello' - }, - 'npm install' - ]; - - let i = 0; - for (const s of seed) { - assert.deepEqual(s, expectedSeed[i]); - i++; - } - assert.deepEqual(i, 2); - - assert.deepEqual(isForce, true); - - assert.deepEqual( - beforeEach, - "await new Promise(resolve => setTimeout(resolve, 1000));\nconst __projectLoc = 'example global variable for tests';" - ); - assert.deepEqual( - afterEach, - "await new Promise(resolve => setTimeout(resolve, 1000));\nlogover.info('after each');" - ); - assert.deepEqual( - beforeAll, - "await new Promise(resolve => setTimeout(resolve, 1000));\nlogover.info('before all');" - ); - assert.deepEqual( - afterAll, - "await new Promise(resolve => setTimeout(resolve, 1000));\nlogover.info('after all');" - ); -} catch (e) { - throw logover.error(e); -} - -logover.debug('All tests passed! 🎉'); diff --git a/.freeCodeCamp/tooling/client-socks.js b/.freeCodeCamp/tooling/client-socks.js deleted file mode 100644 index e44025fc..00000000 --- a/.freeCodeCamp/tooling/client-socks.js +++ /dev/null @@ -1,153 +0,0 @@ -import { parseMarkdown } from './parser.js'; - -export function updateLoader(ws, loader) { - ws.send(parse({ event: 'update-loader', data: { loader } })); -} - -/** - * Update all tests in the tests state - * @param {WebSocket} ws WebSocket connection to the client - * @param {Test[]} tests Array of Test objects - */ -export function updateTests(ws, tests) { - ws.send(parse({ event: 'update-tests', data: { tests } })); -} -/** - * Update single test in the tests state - * @param {WebSocket} ws WebSocket connection to the client - * @param {Test} test Test object - */ -export function updateTest(ws, test) { - ws.send(parse({ event: 'update-test', data: { test } })); -} -/** - * Update the lesson description - * @param {WebSocket} ws WebSocket connection to the client - * @param {string} description Lesson description - */ -export function updateDescription(ws, description) { - ws.send( - parse({ - event: 'update-description', - data: { description } - }) - ); -} -/** - * Update the heading of the lesson - * @param {WebSocket} ws WebSocket connection to the client - * @param {{lessonNumber: number; title: string;}} projectHeading Project heading - */ -export function updateProjectHeading(ws, projectHeading) { - ws.send( - parse({ - event: 'update-project-heading', - data: projectHeading - }) - ); -} -/** - * Update the project state - * @param {WebSocket} ws WebSocket connection to the client - * @param {Project} project Project object - */ -export function updateProject(ws, project) { - ws.send( - parse({ - event: 'update-project', - data: project - }) - ); -} -/** - * Update the projects state - * @param {WebSocket} ws WebSocket connection to the client - * @param {Project[]} projects Array of Project objects - */ -export function updateProjects(ws, projects) { - ws.send( - parse({ - event: 'update-projects', - data: projects - }) - ); -} -/** - * Update the projects state - * @param {WebSocket} ws WebSocket connection to the client - * @param {any} config config object - */ -export function updateFreeCodeCampConfig(ws, config) { - ws.send( - parse({ - event: 'update-freeCodeCamp-config', - data: config - }) - ); -} -/** - * Update hints - * @param {WebSocket} ws WebSocket connection to the client - * @param {string[]} hints Markdown strings - */ -export function updateHints(ws, hints) { - ws.send(parse({ event: 'update-hints', data: { hints } })); -} -/** - * - * @param {WebSocket} ws WebSocket connection to the client - * @param {{error: string; testText: string; passed: boolean;isLoading: boolean;testId: number;}} cons - */ -export function updateConsole(ws, cons) { - if (Object.keys(cons).length) { - if (cons.error) { - const error = `\`\`\`json\n${JSON.stringify( - cons.error, - null, - 2 - )}\n\`\`\``; - cons.error = parseMarkdown(error); - } - } - ws.send(parse({ event: 'update-console', data: { cons } })); -} - -/** - * Update error - * @param {WebSocket} ws WebSocket connection to the client - * @param {Error} error Error object - */ -export function updateError(ws, error) { - ws.send(parse({ event: 'update-error', data: { error } })); -} - -/** - * Update the current locale - * @param {WebSocket} ws WebSocket connection to the client - * @param {string} locale Locale string - */ -export function updateLocale(ws, locale) { - ws.send(parse({ event: 'update-locale', data: locale })); -} - -/** - * Handles the case when a project is finished - * @param {WebSocket} ws WebSocket connection to the client - */ -export function handleProjectFinish(ws) { - ws.send(parse({ event: 'handle-project-finish' })); -} - -export function parse(obj) { - return JSON.stringify(obj); -} - -/** - * Resets the bottom panel (Tests, Console, Hints) of the client to empty state - * @param {WebSocket} ws WebSocket connection to the client - */ -export function resetBottomPanel(ws) { - updateHints(ws, []); - updateTests(ws, []); - updateConsole(ws, {}); -} diff --git a/.freeCodeCamp/tooling/env.js b/.freeCodeCamp/tooling/env.js deleted file mode 100644 index c71aaf89..00000000 --- a/.freeCodeCamp/tooling/env.js +++ /dev/null @@ -1,131 +0,0 @@ -import { readFile, writeFile } from 'fs/promises'; -import { join } from 'path'; -import { logover } from './logger.js'; -import { pluginEvents } from '../plugin/index.js'; - -export const ROOT = process.env.INIT_CWD || process.cwd(); - -export async function getConfig() { - const config = await readFile(join(ROOT, 'freecodecamp.conf.json'), 'utf-8'); - const conf = JSON.parse(config); - const defaultConfig = { - curriculum: { - locales: { - english: 'curriculum/locales/english' - } - }, - config: { - 'projects.json': 'config/projects.json', - 'state.json': 'config/state.json' - } - }; - return { ...defaultConfig, ...conf }; -} - -export const freeCodeCampConfig = await getConfig(); - -export async function getState() { - let defaultState = { - currentProject: null, - locale: 'english', - lastSeed: { - projectDashedName: null, - // All lessons start at 0, but the logic for whether to seed a lesson - // or not is based on the current lesson matching the last seeded lesson - // So, to ensure the first lesson is seeded, this is -1 - lessonNumber: -1 - }, - // All lessons start at 0, but the logic for whether to run certain effects - // is based on the current lesson matching the last lesson - lastWatchChange: -1 - }; - try { - const state = JSON.parse( - await readFile( - join(ROOT, freeCodeCampConfig.config['state.json']), - 'utf-8' - ) - ); - return { ...defaultState, ...state }; - } catch (err) { - logover.error(err); - } - return defaultState; -} - -export async function setState(obj) { - const state = await getState(); - const updatedState = { - ...state, - ...obj - }; - - await writeFile( - join(ROOT, freeCodeCampConfig.config['state.json']), - JSON.stringify(updatedState, null, 2) - ); -} - -/** - * @param {string} projectDashedName Project dashed name - */ -export async function getProjectConfig(projectDashedName) { - const projects = JSON.parse( - await readFile( - join(ROOT, freeCodeCampConfig.config['projects.json']), - 'utf-8' - ) - ); - - const project = projects.find(p => p.dashedName === projectDashedName); - - // Add title and description to project - const { title, description } = await pluginEvents.getProjectMeta( - projectDashedName - ); - project.title = title; - project.description = description; - - const defaultConfig = { - testPollingRate: 333, - currentLesson: 0, - runTestsOnWatch: false, - lastKnownLessonWithHash: 0, - seedEveryLesson: false, - blockingTests: false, - breakOnFailure: false, - useGitBuildOnProduction: false // TODO: Necessary? - }; - if (!project) { - return defaultConfig; - } - return { ...defaultConfig, ...project }; -} - -/** - * - * @param {string} projectDashedName Project dashed name - * @param {object} config Config properties to set - */ -export async function setProjectConfig(projectDashedName, config = {}) { - const projects = JSON.parse( - await readFile( - join(ROOT, freeCodeCampConfig.config['projects.json']), - 'utf-8' - ) - ); - - const updatedProject = { - ...projects.find(p => p.dashedName === projectDashedName), - ...config - }; - - const updatedProjects = projects.map(p => - p.dashedName === projectDashedName ? updatedProject : p - ); - - await writeFile( - join(ROOT, freeCodeCampConfig.config['projects.json']), - JSON.stringify(updatedProjects, null, 2) - ); -} diff --git a/.freeCodeCamp/tooling/git/build.js b/.freeCodeCamp/tooling/git/build.js deleted file mode 100644 index 1a27663f..00000000 --- a/.freeCodeCamp/tooling/git/build.js +++ /dev/null @@ -1,90 +0,0 @@ -// This file handles creating the Git curriculum branches -import { join } from 'path'; -import { getState, setState } from '../env.js'; -import { - getCommands, - getFilesWithSeed, - getLessonFromFile, - getLessonSeed -} from '../parser.js'; -import { runCommands, runSeed } from '../seed.js'; -import { - checkoutMain, - commit, - deleteBranch, - initCurrentProjectBranch, - pushProject -} from './gitterizer.js'; -import { logover } from '../logger.js'; - -const PROJECT_LIST = ['project-1']; - -for (const project of PROJECT_LIST) { - await setState({ currentProject: project }); - try { - await deleteBranch(project); - await buildProject(); - } catch (e) { - logover.error('Failed to build project: ', project); - await deleteBranch(project); - throw new Error(e); - } finally { - await checkoutMain(); - logover.info('✅ Successfully built project: ', project); - } -} -logover.info('✅ Successfully built all projects'); - -async function buildProject() { - const { currentProject } = await getState(); - const FILE = join( - ROOT, - freeCodeCampConfig.curriculum.locales['english'], - project.dashedName + '.md' - ); - - try { - await initCurrentProjectBranch(); - } catch (e) { - logover.error('🔴 Failed to create a branch for ', currentProject); - throw new Error(e); - } - - let lessonNumber = 1; - let lesson = await getLessonFromFile(FILE, lessonNumber); - if (!lesson) { - return Promise.reject( - new Error(`🔴 No lesson found for ${currentProject}`) - ); - } - while (lesson) { - const seed = getLessonSeed(lesson); - - if (seed) { - const commands = getCommands(seed); - const filesWithSeed = getFilesWithSeed(seed); - try { - await runCommands(commands); - // TODO: Not correct signature - await runSeed(filesWithSeed); - } catch (e) { - logover.error('🔴 Failed to run seed for lesson: ', lessonNumber); - throw new Error(e); - } - } - try { - // Always commit? Or, skip when seed is empty? - await commit(lessonNumber); - } catch (e) { - throw new Error(e); - } - lessonNumber++; - lesson = await getLessonFromFile(FILE, lessonNumber); - } - - try { - await pushProject(); - } catch (e) { - throw new Error(e); - } -} diff --git a/.freeCodeCamp/tooling/git/gitterizer.js b/.freeCodeCamp/tooling/git/gitterizer.js deleted file mode 100644 index ca890271..00000000 --- a/.freeCodeCamp/tooling/git/gitterizer.js +++ /dev/null @@ -1,207 +0,0 @@ -// This file handles the fetching/parsing of the Git status of the project -import { promisify } from 'util'; -import { exec } from 'child_process'; -import { getState, setState } from '../env.js'; -import { logover } from '../logger.js'; -const execute = promisify(exec); - -/** - * Runs the following commands: - * - * ```bash - * git add . - * git commit --allow-empty -m "()" - * ``` - * - * @param {number} lessonNumber - * @returns {Promise} - */ -export async function commit(lessonNumber) { - try { - const { stdout, stderr } = await execute( - `git add . && git commit --allow-empty -m "(${lessonNumber})"` - ); - if (stderr) { - logover.error('Failed to commit lesson: ', lessonNumber); - throw new Error(stderr); - } - } catch (e) { - return Promise.reject(e); - } - return Promise.resolve(); -} - -/** - * Initialises a new branch for the `CURRENT_PROJECT` - * @returns {Promise} - */ -export async function initCurrentProjectBranch() { - const { currentProject } = await getState(); - try { - const { stdout, stderr } = await execute( - `git checkout -b ${currentProject}` - ); - // SILlY GIT PUTS A BRANCH SWITCH INTO STDERR!!! - // if (stderr) { - // throw new Error(stderr); - // } - } catch (e) { - return Promise.reject(e); - } - return Promise.resolve(); -} - -/** - * Returns the commit hash of the branch `origin/` - * @param {number} number - * @returns {Promise} - */ -export async function getCommitHashByNumber(number) { - const { lastKnownLessonWithHash, currentProject } = await getState(); - try { - const { stdout, stderr } = await execute( - `git log origin/${currentProject} --oneline --grep="(${number})" --` - ); - if (stderr) { - throw new Error(stderr); - } - const hash = stdout.match(/\w+/)?.[0]; - // This keeps track of the latest known commit in case there are no commits from one lesson to the next - if (!hash) { - return getCommitHashByNumber(lastKnownLessonWithHash); - } - await setState({ lastKnownLessonWithHash: number }); - return hash; - } catch (e) { - throw new Error(e); - } -} - -/** - * Aborts and in-progress `cherry-pick` - * @returns {Promise} - */ -async function ensureNoUnfinishedGit() { - try { - const { stdout, stderr } = await execute(`git cherry-pick --abort`); - // Throwing in a `try` probably does not make sense - if (stderr) { - throw new Error(stderr); - } - } catch (e) { - return Promise.reject(e); - } - return Promise.resolve(); -} - -/** - * Git cleans the current branch, then `cherry-pick`s the commit hash found by `lessonNumber` - * @param {number} lessonNumber - * @returns {Promise} - */ -export async function setFileSystemToLessonNumber(lessonNumber) { - await ensureNoUnfinishedGit(); - const endHash = await getCommitHashByNumber(lessonNumber); - const firstHash = await getCommitHashByNumber(1); - try { - // TODO: Continue on this error? Or, bail? - if (!endHash || !firstHash) { - throw new Error('Could not find commit hash'); - } - // VOLUME BINDING? - // - // TODO: Probably do not want to always completely clean for each lesson - if (firstHash === endHash) { - await execute(`git clean -f -q -- . && git cherry-pick ${endHash}`); - } else { - // TODO: Why not git checkout ${endHash} - const { stdout, stderr } = await execute( - `git clean -f -q -- . && git cherry-pick ${firstHash}^..${endHash}` - ); - if (stderr) { - throw new Error(stderr); - } - } - } catch (e) { - return Promise.reject(e); - } - return Promise.resolve(); -} - -/** - * Pushes the `` branch to `origin` - * @returns {Promise} - */ -export async function pushProject() { - const { currentProject } = await getState(); - try { - const { stdout, stderr } = await execute( - `git push origin ${currentProject} --force` - ); - // if (stderr) { - // throw new Error(stderr); - // } - } catch (e) { - logover.error('Failed to push project ', currentProject); - return Promise.reject(e); - } - return Promise.resolve(); -} - -/** - * Checks out the `main` branch - * - * **IMPORTANT**: This function restores any/all git changes that are uncommitted. - * @returns {Promise} - */ -export async function checkoutMain() { - try { - await execute('git restore .'); - const { stdout, stderr } = await execute(`git checkout main`); - // if (stderr) { - // throw new Error(stderr); - // } - } catch (e) { - return Promise.reject(e); - } - return Promise.resolve(); -} - -/** - * If the given branch is found to exist, deletes the branch - * @param {string} branch - * @returns {Promise} - */ -export async function deleteBranch(branch) { - const isBranchExists = await branchExists(branch); - if (!isBranchExists) { - return Promise.resolve(); - } - logover.warn('Deleting branch ', branch); - try { - await checkoutMain(); - const { stdout, stderr } = await execute(`git branch -D ${branch}`); - logover.info(stdout); - // if (stderr) { - // throw new Error(stderr); - // } - } catch (e) { - logover.error('Failed to delete branch: ', branch); - return Promise.reject(e); - } - return Promise.resolve(); -} - -/** - * Checks if the given branch exists - * @param {string} branch - * @returns {Promise} - */ -export async function branchExists(branch) { - try { - const { stdout, stderr } = await execute(`git branch --list ${branch}`); - return Promise.resolve(stdout.includes(branch)); - } catch (e) { - return Promise.reject(e); - } -} diff --git a/.freeCodeCamp/tooling/hot-reload.js b/.freeCodeCamp/tooling/hot-reload.js deleted file mode 100644 index b0f2a40e..00000000 --- a/.freeCodeCamp/tooling/hot-reload.js +++ /dev/null @@ -1,140 +0,0 @@ -// This file handles the watching of the /curriculum folder for changes -// and executing the command to run the tests for the next (current) lesson -import { getState, getProjectConfig, ROOT, freeCodeCampConfig } from './env.js'; -import { runLesson } from './lesson.js'; -import { runTests } from './tests/main.js'; -import { watch } from 'chokidar'; -import { logover } from './logger.js'; -import path from 'path'; -import { readdir } from 'fs/promises'; - -const defaultPathsToIgnore = [ - '.logs/.temp.log', - 'config/', - '/node_modules/', - '.git/', - '/target/', - '/test-ledger/' -]; - -export const pathsToIgnore = - freeCodeCampConfig.hotReload?.ignore || defaultPathsToIgnore; - -export const watcher = watch(ROOT, { - ignoreInitial: true, - ignored: path => pathsToIgnore.some(p => path.includes(p)) -}); - -export function hotReload(ws, pathsToIgnore = defaultPathsToIgnore) { - logover.info(`Watching for file changes on ${ROOT}`); - let isWait = false; - let testsRunning = false; - let isClearConsole = false; - - // hotReload is called on connection, which can happen mulitple times due to client reload/disconnect. - // This ensures the following does not happen: - // > MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 all listeners added to [FSWatcher]. - watcher.removeAllListeners('all'); - - watcher.on('all', async (event, name) => { - if (name && !pathsToIgnore.find(p => name.includes(p))) { - if (isWait) return; - const { currentProject } = await getState(); - if (!currentProject) { - return; - } - - const { testPollingRate, runTestsOnWatch } = await getProjectConfig( - currentProject - ); - isWait = setTimeout(() => { - isWait = false; - }, testPollingRate); - - if (isClearConsole) { - console.clear(); - } - - await runLesson(ws, currentProject); - if (runTestsOnWatch && !testsRunning) { - logover.debug(`Watcher: ${event} - ${name}`); - testsRunning = true; - await runTests(ws, currentProject); - testsRunning = false; - } - } - }); -} - -/** - * Stops the global `watcher` from watching the entire workspace. - */ -export function unwatchAll() { - const watched = watcher.getWatched(); - for (const [dir, files] of Object.entries(watched)) { - for (const file of files) { - watcher.unwatch(path.join(dir, file)); - } - } -} - -// Need to handle -// From ROOT, must add all directories before file/s -// path.dirname... all the way to ROOT -// path.isAbsolute to find out if what was passed into `meta` is absolute or relative -// path.parse to get the dir and base -// path.relative(ROOT, path) to get the relative path from ROOT -// path.resolve directly on `meta`? -/** - * **Example:** - * - Assuming ROOT is `/home/freeCodeCampOS/self` - * - Takes `lesson-watcher/src/watched.js` - * - Calls `watcher.add` on each of these in order: - * - `/home/freeCodeCampOS/self` - * - `/home/freeCodeCampOS/self/lesson-watcher` - * - `/home/freeCodeCampOS/self/lesson-watcher/src` - * - `/home/freeCodeCampOS/self/lesson-watcher/src/watched.js` - * @param {string} pathRelativeToRoot - */ -export function watchPathRelativeToRoot(pathRelativeToRoot) { - const paths = getAllPathsWithRoot(pathRelativeToRoot); - for (const path of paths) { - watcher.add(path); - } -} - -function getAllPathsWithRoot(pathRelativeToRoot) { - const paths = []; - let currentPath = pathRelativeToRoot; - while (currentPath !== ROOT) { - paths.push(currentPath); - currentPath = path.dirname(currentPath); - } - paths.push(ROOT); - // The order does not _seem_ to matter, but the theory says it should - return paths.reverse(); -} - -/** - * Adds all folders and files to the `watcher` instance. - * - * Does nothing with the `pathsToIgnore`, because they are already ignored by the `watcher`. - */ -export async function watchAll() { - await watchPath(ROOT); -} - -async function watchPath(rootPath) { - const paths = await readdir(rootPath, { withFileTypes: true }); - for (const p of paths) { - const fullPath = path.join(rootPath, p.name); - // if (pathsToIgnore.find(i => fullPath.includes(i))) { - // console.log('Ignoring: ', fullPath); - // continue; - // } - watcher.add(fullPath); - if (p.isDirectory()) { - await watchPath(fullPath); - } - } -} diff --git a/.freeCodeCamp/tooling/lesson.js b/.freeCodeCamp/tooling/lesson.js deleted file mode 100644 index 29ee50d8..00000000 --- a/.freeCodeCamp/tooling/lesson.js +++ /dev/null @@ -1,104 +0,0 @@ -// This file parses answer files for lesson content -import { join } from 'path'; -import { - updateDescription, - updateProjectHeading, - updateTests, - updateProject, - updateError, - resetBottomPanel -} from './client-socks.js'; -import { ROOT, getState, getProjectConfig, setState } from './env.js'; -import { logover } from './logger.js'; -import { seedLesson } from './seed.js'; -import { pluginEvents } from '../plugin/index.js'; -import { - unwatchAll, - watchAll, - watchPathRelativeToRoot, - watcher -} from './hot-reload.js'; - -/** - * Runs the lesson from the `projectDashedName` config. - * @param {WebSocket} ws WebSocket connection to the client - * @param {string} projectDashedName - */ -export async function runLesson(ws, projectDashedName) { - const project = await getProjectConfig(projectDashedName); - const { isIntegrated, dashedName, seedEveryLesson, currentLesson } = project; - const { lastSeed, lastWatchChange } = await getState(); - try { - const { description, seed, isForce, tests, meta } = - await pluginEvents.getLesson(projectDashedName, currentLesson); - - // TODO: Consider performance optimizations - // - Do not run at all if whole project does not contain any `meta`. - await handleWatcher(meta, { lastWatchChange, currentLesson }); - - if (currentLesson === 0) { - await pluginEvents.onProjectStart(project); - } - await pluginEvents.onLessonLoad(project); - - updateProject(ws, project); - - if (!isIntegrated) { - updateTests( - ws, - tests.reduce((acc, curr, i) => { - return [ - ...acc, - { passed: false, testText: curr[0], testId: i, isLoading: false } - ]; - }, []) - ); - } - resetBottomPanel(ws); - - const { title } = await pluginEvents.getProjectMeta(projectDashedName); - updateProjectHeading(ws, { - title, - lessonNumber: currentLesson - }); - updateDescription(ws, description); - - // If the current lesson has not already been seeded, seed it - // Otherwise, Campers can click the "Reset Project" button to re-seed a lesson - if ( - lastSeed?.projectDashedName !== dashedName || - (lastSeed?.projectDashedName === dashedName && - lastSeed?.lessonNumber !== currentLesson) - ) { - if (seed) { - // force flag overrides seed flag - if ((seedEveryLesson && !isForce) || (!seedEveryLesson && isForce)) { - await seedLesson(ws, dashedName); - } - } - } - } catch (err) { - updateError(ws, err); - logover.error(err); - } -} - -async function handleWatcher(meta, { lastWatchChange, currentLesson }) { - // Calling `watcher` methods takes a performance hit. So, check is behind a check that the lesson has changed. - if (lastWatchChange !== currentLesson) { - if (meta?.watch) { - unwatchAll(); - for (const path of meta.watch) { - const toWatch = join(ROOT, path); - watchPathRelativeToRoot(toWatch); - } - } else if (meta?.ignore) { - await watchAll(); - watcher.unwatch(meta.ignore); - } else { - // Reset watcher back to default/freecodecamp.conf.json - await watchAll(); - } - } - await setState({ lastWatchChange: currentLesson }); -} diff --git a/.freeCodeCamp/tooling/logger.js b/.freeCodeCamp/tooling/logger.js deleted file mode 100644 index f0670da0..00000000 --- a/.freeCodeCamp/tooling/logger.js +++ /dev/null @@ -1,5 +0,0 @@ -import { Logger } from 'logover'; - -export const logover = new Logger({ - level: process.env.NODE_ENV === 'development' ? 'debug' : 'info' -}); diff --git a/.freeCodeCamp/tooling/parser.js b/.freeCodeCamp/tooling/parser.js deleted file mode 100644 index 3501599e..00000000 --- a/.freeCodeCamp/tooling/parser.js +++ /dev/null @@ -1,391 +0,0 @@ -import { lexer } from 'marked'; - -/** - * A class that takes a Markdown string, uses the markedjs package to tokenize it, and provides convenience methods to access different tokens in the token tree - */ -export class CoffeeDown { - constructor(tokensOrMarkdown, caller = null) { - this.caller = caller; - if (typeof tokensOrMarkdown == 'string') { - this.tokens = lexer(tokensOrMarkdown); - } else if (Array.isArray(tokensOrMarkdown)) { - this.tokens = tokensOrMarkdown; - } else { - this.tokens = [tokensOrMarkdown]; - } - } - - getProjectMeta() { - // There should only be one H1 in the project which is the title - const title = this.tokens.find( - t => t.type === 'heading' && t.depth === 1 - ).text; - - const firstLessonMarker = this.tokens.findIndex(t => { - return ( - t.type === 'heading' && - t.depth === 2 && - Number.isInteger(parseFloat(t.text)) - ); - }); - const tokensBeforeFirstLesson = this.tokens.slice(0, firstLessonMarker); - - // The first paragraph before the lesson marker should be the description - const description = tokensBeforeFirstLesson.find( - t => t.type === 'paragraph' - ).text; - - // First codeblock before the lesson marker is extra meta within a JSON codeblock - const jsonMeta = - tokensBeforeFirstLesson.find(t => t.type === 'code' && t.lang === 'json') - ?.text ?? '{}'; - const meta = JSON.parse(jsonMeta); - - // All H2 elements with an integer for text are lesson headings - const numberOfLessons = this.tokens.filter( - t => - t.type === 'heading' && - t.depth === 2 && - Number.isInteger(parseFloat(t.text)) - ).length; - return { title, description, numberOfLessons, ...meta }; - } - - getHeading(depth, text, caller) { - if (this.caller !== 'getLesson') { - throw new Error( - `${caller} must be called on getLesson. Called on ${this.caller}` - ); - } - const tokens = []; - let take = false; - for (const token of this.tokens) { - if ( - token.type === 'heading' && - token.depth <= depth && - TOKENS.some(t => t.marker === token.text) - ) { - take = false; - } - if (take) { - tokens.push(token); - } - if ( - token.type === 'heading' && - token.depth === depth && - token.text === text - ) { - take = true; - } - } - return new CoffeeDown(tokens, caller); - } - - getLesson(lessonNumber) { - const lesson = this.#getLesson(lessonNumber); - const description = lesson.getDescription().markdown; - const tests = lesson.getTests().tests; - const seed = lesson.getSeed().seed; - const isForce = lesson - .getSeed() - .tokens.some( - t => t.type === 'heading' && t.depth === 4 && t.text === '--force--' - ); - const hints = lesson.getHints().hints; - const beforeAll = lesson.getBeforeAll().code; - const afterAll = lesson.getAfterAll().code; - const beforeEach = lesson.getBeforeEach().code; - const afterEach = lesson.getAfterEach().code; - - const meta = lesson.getMeta(); - return { - meta, - description, - tests, - seed, - hints, - beforeAll, - afterAll, - beforeEach, - afterEach, - isForce - }; - } - - #getLesson(lessonNumber) { - const tokens = []; - let take = false; - for (const token of this.tokens) { - if ( - token.type === 'heading' && - token.depth === 2 && - (parseInt(token.text, 10) === lessonNumber + 1 || - token.text === '--fcc-end--') - ) { - take = false; - } - if (take) { - tokens.push(token); - } - if (token.type === 'heading' && token.depth === 2) { - if (parseInt(token.text, 10) === lessonNumber) { - take = true; - } - } - } - return new CoffeeDown(tokens, 'getLesson'); - } - - getDescription() { - return this.getHeading(3, '--description--', 'getDescription'); - } - - getTests() { - return this.getHeading(3, '--tests--', 'getTests'); - } - - getSeed() { - return this.getHeading(3, '--seed--', 'getSeed'); - } - - getHints() { - return this.getHeading(3, '--hints--', 'getHints'); - } - - getBeforeAll() { - return this.getHeading(3, '--before-all--', 'getBeforeAll'); - } - - getAfterAll() { - return this.getHeading(3, '--after-all--', 'getAfterAll'); - } - - getBeforeEach() { - return this.getHeading(3, '--before-each--', 'getBeforeEach'); - } - - getAfterEach() { - return this.getHeading(3, '--after-each--', 'getAfterEach'); - } - - getMeta() { - const firstHeadingMarker = this.tokens.findIndex(t => { - return t.type === 'heading' && t.depth === 3; - }); - const tokensBeforeFirstHeading = this.tokens.slice(0, firstHeadingMarker); - const jsonMeta = - tokensBeforeFirstHeading.find(t => t.type === 'code' && t.lang === 'json') - ?.text ?? '{}'; - return JSON.parse(jsonMeta); - } - - /** - * Get first code block text from tokens - * - * Meant to be used with `getBeforeAll`, `getAfterAll`, `getBeforeEach`, and `getAfterEach` - */ - get code() { - const callers = [ - 'getBeforeAll', - 'getAfterAll', - 'getBeforeEach', - 'getAfterEach' - ]; - if (!callers.includes(this.caller)) { - throw new Error( - `code must be called on "${callers.join(', ')}". Called on ${ - this.caller - }` - ); - } - return this.tokens.find(t => t.type === 'code')?.text; - } - - get seed() { - if (this.caller !== 'getSeed') { - throw new Error( - `seedToIterator must be called on getSeed. Called on ${this.caller}` - ); - } - return seedToIterator(this.tokens); - } - - get tests() { - if (this.caller !== 'getTests') { - throw new Error( - `textsAndTests must be called on getTests. Called on ${this.caller}` - ); - } - const textTokens = []; - const testTokens = []; - for (const token of this.tokens) { - if (token.type === 'paragraph') { - textTokens.push(token); - } - if (token.type === 'code') { - testTokens.push(token); - } - } - const texts = textTokens.map(t => t.text); - const tests = testTokens.map(t => t.text); - return texts.map((text, i) => [text, tests[i]]); - } - - get hints() { - if (this.caller !== 'getHints') { - throw new Error( - `hints must be called on getHints. Called on ${this.caller}` - ); - } - const hintTokens = [[]]; - let currentHint = 0; - for (const token of this.tokens) { - if (token.type === 'heading' && token.depth === 4) { - if (token.text != currentHint) { - currentHint = token.text; - hintTokens[currentHint] = []; - } - } else { - hintTokens[currentHint].push(token); - } - } - const hints = hintTokens - .map(t => t.map(t => t.raw).join('')) - .filter(Boolean); - return hints; - } - - get markdown() { - return this.tokens.map(t => t.raw).join(''); - } - - get text() { - return this.tokens.map(t => t.text).join(''); - } -} - -function seedToIterator(tokens) { - const seed = []; - const sectionTokens = {}; - let currentSection = 0; - for (const token of tokens) { - if ( - token.type === 'heading' && - token.depth === 4 && - token.text !== '--force--' - ) { - if (token.text !== currentSection) { - currentSection = token.text; - sectionTokens[currentSection] = {}; - } - } else if (token.type === 'code') { - sectionTokens[currentSection] = token; - } - } - for (const [filePath, { text }] of Object.entries(sectionTokens)) { - if (filePath === '--cmd--') { - seed.push(text); - } else { - seed.push({ - filePath: filePath.slice(3, filePath.length - 3), - fileSeed: text - }); - } - } - return seed; -} - -import { marked } from 'marked'; -import { markedHighlight } from 'marked-highlight'; -import Prism from 'prismjs'; -import loadLanguages from 'prismjs/components/index.js'; - -loadLanguages([ - 'javascript', - 'css', - 'html', - 'json', - 'markdown', - 'sql', - 'rust', - 'typescript', - 'jsx', - 'c', - 'csharp', - 'cpp', - 'dotnet', - 'python', - 'pug', - 'handlebars' -]); - -marked.use( - markedHighlight({ - highlight: (code, lang) => { - if (Prism.languages[lang]) { - return Prism.highlight(code, Prism.languages[lang], String(lang)); - } else { - return code; - } - } - }) -); - -export function parseMarkdown(markdown) { - return marked.parse(markdown, { gfm: true }); -} - -const TOKENS = [ - { - marker: /\d+/, - depth: 2 - }, - { - marker: '--fcc-end--', - depth: 2 - }, - { - marker: '--description--', - depth: 3 - }, - { - marker: '--tests--', - depth: 3 - }, - { - marker: '--seed--', - depth: 3 - }, - { - marker: '--hints--', - depth: 3 - }, - { - marker: '--before-all--', - depth: 3 - }, - { - marker: '--after-all--', - depth: 3 - }, - { - marker: '--before-each--', - depth: 3 - }, - { - marker: '--after-each--', - depth: 3 - }, - { - marker: '--cmd--', - depth: 4 - }, - { - marker: /(?<=--)[^"]+(?="--)/, - depth: 4 - }, - { - marker: '--force--', - depth: 4 - } -]; diff --git a/.freeCodeCamp/tooling/reset.js b/.freeCodeCamp/tooling/reset.js deleted file mode 100644 index d9a0374c..00000000 --- a/.freeCodeCamp/tooling/reset.js +++ /dev/null @@ -1,61 +0,0 @@ -// Handles all the resetting of the projects -import { resetBottomPanel, updateError, updateLoader } from './client-socks.js'; -import { getProjectConfig, getState } from './env.js'; -import { logover } from './logger.js'; -import { runCommand, runLessonSeed } from './seed.js'; -import { pluginEvents } from '../plugin/index.js'; - -/** - * Resets the current project by running, in order, every seed - * @param {WebSocket} ws - */ -export async function resetProject(ws) { - resetBottomPanel(ws); - // Get commands and handle file setting - const { currentProject } = await getState(); - const project = await getProjectConfig(currentProject); - const { currentLesson } = project; - updateLoader(ws, { - isLoading: true, - progress: { total: currentLesson, count: 0 } - }); - - let lessonNumber = 0; - try { - await gitResetCurrentProjectDir(); - while (lessonNumber <= currentLesson) { - const { seed } = await pluginEvents.getLesson( - currentProject, - lessonNumber - ); - if (seed) { - await runLessonSeed(seed, lessonNumber); - } - lessonNumber++; - updateLoader(ws, { - isLoading: true, - progress: { total: currentLesson, count: lessonNumber } - }); - } - } catch (err) { - updateError(ws, err); - logover.error(err); - } - updateLoader(ws, { - isLoading: false, - progress: { total: 1, count: 1 } - }); -} - -async function gitResetCurrentProjectDir() { - const { currentProject } = await getState(); - const project = await getProjectConfig(currentProject); - try { - logover.debug(`Cleaning '${project.dashedName}'`); - const { stdout, stderr } = await runCommand( - `git clean -f -q -- ${project.dashedName}` - ); - } catch (e) { - logover.error(e); - } -} diff --git a/.freeCodeCamp/tooling/seed.js b/.freeCodeCamp/tooling/seed.js deleted file mode 100644 index 47851c26..00000000 --- a/.freeCodeCamp/tooling/seed.js +++ /dev/null @@ -1,116 +0,0 @@ -// This file handles seeding the lesson contents with the seed in markdown. -import { join } from 'path'; -import { - ROOT, - getState, - freeCodeCampConfig, - getProjectConfig, - setState -} from './env.js'; -import { writeFile } from 'fs/promises'; -import { promisify } from 'util'; -import { exec } from 'child_process'; -import { logover } from './logger.js'; -import { updateLoader, updateError } from './client-socks.js'; -import { watcher } from './hot-reload.js'; -import { pluginEvents } from '../plugin/index.js'; -const execute = promisify(exec); - -/** - * Seeds the current lesson - * @param {WebSocket} ws - * @param {string} projectDashedName - */ -export async function seedLesson(ws, projectDashedName) { - updateLoader(ws, { - isLoading: true, - progress: { total: 2, count: 1 } - }); - const project = await getProjectConfig(projectDashedName); - const { currentLesson } = project; - - try { - const { seed } = await pluginEvents.getLesson( - projectDashedName, - currentLesson - ); - - await runLessonSeed(seed, currentLesson); - await setState({ - lastSeed: { - projectDashedName, - lessonNumber: currentLesson - } - }); - } catch (e) { - updateError(ws, e); - logover.error(e); - } - updateLoader(ws, { isLoading: false, progress: { total: 1, count: 1 } }); -} - -/** - * Runs the given array of commands in order - * @param {string[]} commands - Array of commands to run - */ -export async function runCommands(commands) { - // Execute the following commands in the shell - for (const command of commands) { - const { stdout, stderr } = await execute(command); - if (stdout) { - logover.debug(stdout); - } - if (stderr) { - logover.error(stderr); - return Promise.reject(stderr); - } - } - return Promise.resolve(); -} - -/** - * Runs the given command - * @param {string} command - Commands to run - */ -export async function runCommand(command, path = '.') { - const cmdOut = await execute(command, { - cwd: join(ROOT, path), - shell: '/bin/bash' - }); - return cmdOut; -} - -/** - * Seeds the given path relative to root with the given seed - */ -export async function runSeed(fileSeed, filePath) { - const path = join(ROOT, filePath); - await writeFile(path, fileSeed); -} - -/** - * Runs the given seed for the given project and lesson number - * @param {string} seed - * @param {number} currentLesson - */ -export async function runLessonSeed(seed, currentLesson) { - try { - for (const cmdOrFile of seed) { - if (typeof cmdOrFile === 'string') { - const { stdout, stderr } = await runCommand(cmdOrFile); - if (stdout || stderr) { - logover.debug(stdout, stderr); - } - } else { - const { filePath, fileSeed } = cmdOrFile; - // Stop watching file being seeded to prevent triggering tests on hot reload - watcher.unwatch(filePath); - await runSeed(fileSeed, filePath); - watcher.add(filePath); - } - } - } catch (e) { - logover.error('Failed to run seed for lesson: ', currentLesson); - throw new Error(e); - } -} diff --git a/.freeCodeCamp/tooling/server.js b/.freeCodeCamp/tooling/server.js deleted file mode 100644 index 43da20f4..00000000 --- a/.freeCodeCamp/tooling/server.js +++ /dev/null @@ -1,300 +0,0 @@ -import express from 'express'; -import { readFile } from 'fs/promises'; -import { getWorkerPool, runTests } from './tests/main.js'; -import { - getProjectConfig, - getState, - ROOT, - setProjectConfig, - setState, - getConfig -} from './env.js'; - -import { WebSocketServer } from 'ws'; -import { runLesson } from './lesson.js'; -import { - updateProjects, - updateFreeCodeCampConfig, - updateLocale -} from './client-socks.js'; -import { hotReload } from './hot-reload.js'; -import { hideAll, showFile, showAll } from './utils.js'; -import { join } from 'path'; -import { logover } from './logger.js'; -import { resetProject } from './reset.js'; -import { validateCurriculum } from './validate.js'; -import { pluginEvents } from '../plugin/index.js'; - -const freeCodeCampConfig = await getConfig(); - -await updateProjectConfig(); - -if (process.env.NODE_ENV === 'development') { - await validateCurriculum(); -} - -const app = express(); - -app.use( - express.static( - join(ROOT, 'node_modules/@freecodecamp/freecodecamp-os/.freeCodeCamp/dist') - ) -); - -// Serve static dir(s) -const staticDir = freeCodeCampConfig.client?.static; -if (Array.isArray(staticDir)) { - for (const dir of staticDir) { - if (typeof dir === 'object') { - for (const [route, dir] of Object.entries(dir)) { - app.use(route, express.static(join(ROOT, dir))); - } - } else if (typeof dir === 'string') { - app.use(express.static(join(ROOT, dir))); - } - } -} else if (typeof staticDir === 'string') { - app.use(express.static(join(ROOT, staticDir))); -} else if (typeof staticDir === 'object') { - for (const [route, dir] of Object.entries(staticDir)) { - app.use(route, express.static(join(ROOT, dir))); - } -} -async function handleRunTests(ws, data) { - const { currentProject } = await getState(); - await runTests(ws, currentProject); - ws.send(parse({ data: { event: data.event }, event: 'RESPONSE' })); -} - -async function handleResetProject(ws, data) { - await resetProject(ws); - ws.send(parse({ data: { event: data.event }, event: 'RESPONSE' })); -} -function handleResetLesson(ws, data) {} - -async function handleGoToNextLesson(ws, data) { - const { currentProject } = await getState(); - const project = await getProjectConfig(currentProject); - const nextLesson = project.currentLesson + 1; - - if (nextLesson > 0 && nextLesson <= project.numberOfLessons - 1) { - await setProjectConfig(currentProject, { currentLesson: nextLesson }); - await runLesson(ws, project.dashedName); - } - ws.send(parse({ data: { event: data.event }, event: 'RESPONSE' })); -} - -async function handleGoToPreviousLesson(ws, data) { - const { currentProject } = await getState(); - const project = await getProjectConfig(currentProject); - const prevLesson = project.currentLesson - 1; - - if (prevLesson >= 0 && prevLesson <= project.numberOfLessons - 1) { - await setProjectConfig(currentProject, { currentLesson: prevLesson }); - await runLesson(ws, project.dashedName); - } - ws.send(parse({ data: { event: data.event }, event: 'RESPONSE' })); -} - -/** - * Gets the projects from `projects.json` and adds the neta to each project object. - * - * The client relies on each project having a title, description, and tags. - */ -async function getProjects() { - const projects = JSON.parse( - await readFile( - join(ROOT, freeCodeCampConfig.config['projects.json']), - 'utf-8' - ) - ); - - for (const project of projects) { - const { - title, - description, - tags = [] - } = await pluginEvents.getProjectMeta(project.dashedName); - project.tags = tags; - project.title = title; - project.description = description; - } - return projects; -} - -async function handleConnect(ws) { - const projects = await getProjects(); - - updateProjects(ws, projects); - updateFreeCodeCampConfig(ws, freeCodeCampConfig); - const { currentProject, locale } = await getState(); - updateLocale(ws, locale); - if (!currentProject) { - return; - } - const project = await getProjectConfig(currentProject); - runLesson(ws, project.dashedName); -} - -async function handleSelectProject(ws, data) { - const projects = JSON.parse( - await readFile( - join(ROOT, freeCodeCampConfig.config['projects.json']), - 'utf-8' - ) - ); - const selectedProject = projects.find(p => p.id === data?.data?.id); - // TODO: Should this set the currentProject to `null` (empty string)? - // for the case where the Camper has navigated to the landing page. - await setState({ currentProject: selectedProject?.dashedName ?? null }); - if (!selectedProject && !data?.data?.id) { - return ws.send(parse({ data: { event: data.event }, event: 'RESPONSE' })); - } - - // Disabled whilst in development because it is annoying - if (process.env.NODE_ENV === 'production') { - await hideAll(); - await showFile(selectedProject.dashedName); - } else { - await showAll(); - } - await runLesson(ws, selectedProject.dashedName); - return ws.send(parse({ data: { event: data.event }, event: 'RESPONSE' })); -} - -async function handleRequestData(ws, data) { - if (data?.data?.request === 'projects') { - const projects = await getProjects(); - updateProjects(ws, projects); - } - ws.send(parse({ data: { event: data.event }, event: 'RESPONSE' })); -} - -function handleCancelTests(ws, data) { - const workerPool = getWorkerPool(); - for (const worker of workerPool) { - worker.terminate(); - } - ws.send(parse({ data: { event: data.event }, event: 'RESPONSE' })); -} - -async function handleRunClientCode(ws, data) { - const code = data?.data; - if (!code) { - return; - } - try { - let __result; - await eval(`(async () => {${code}})()`); - ws.send( - parse({ - data: { event: data.event, __result }, - event: 'RESPONSE' - }) - ); - } catch (e) { - logover.error('Error running client code:\n', e); - ws.send( - parse({ - data: { event: data.event, error: e.message }, - event: 'RESPONSE' - }) - ); - } -} - -async function handleChangeLanguage(ws, data) { - await setState({ locale: data?.data?.locale }); - updateLocale(ws, data?.data?.locale); - const projects = await getProjects(); - updateProjects(ws, projects); -} - -const PORT = freeCodeCampConfig.port || 8080; - -const server = app.listen(PORT, () => { - logover.info(`Server listening on port ${PORT}`); -}); - -const handle = { - connect: (ws, data) => { - handleConnect(ws); - }, - 'run-tests': handleRunTests, - 'reset-project': handleResetProject, - 'go-to-next-lesson': handleGoToNextLesson, - 'go-to-previous-lesson': handleGoToPreviousLesson, - 'request-data': handleRequestData, - 'select-project': handleSelectProject, - 'cancel-tests': handleCancelTests, - 'change-language': handleChangeLanguage, - '__run-client-code': handleRunClientCode -}; - -const wss = new WebSocketServer({ server }); - -wss.on('connection', function connection(ws) { - hotReload(ws, freeCodeCampConfig.hotReload?.ignore); - ws.on('message', function message(data) { - const parsedData = parseBuffer(data); - handle[parsedData.event]?.(ws, parsedData); - }); - (async () => { - const projects = await getProjects(); - updateProjects(ws, projects); - updateFreeCodeCampConfig(ws, freeCodeCampConfig); - })(); - sock('connect', { message: "Server says 'Hello!'" }); - - function sock(type, data = {}) { - ws.send(parse({ event: type, data })); - } -}); - -function parse(obj) { - return JSON.stringify(obj); -} - -function parseBuffer(buf) { - return JSON.parse(buf.toString()); -} - -/** - * Files currently under ownership by another thread. - */ -const RACING_FILES = new Set(); -const FREEDOM_TIMEOUT = 100; - -/** - * Adds an operation to the race queue. If a file is already in the queue, the op is delayed until the file is released. - * @param {string} filepath Path to file to move - * @param {*} cb Callback to call once file is free - */ -async function addToRaceQueue(filepath, cb) { - const isFileFree = await new Promise(resolve => { - setTimeout(() => { - if (!RACING_FILES.has(filepath)) { - resolve(true); - } - }, FREEDOM_TIMEOUT); - }); - if (isFileFree) { - RACING_FILES.add(filepath); - cb(); - } -} - -async function updateProjectConfig() { - const projects = JSON.parse( - await readFile( - join(ROOT, freeCodeCampConfig.config['projects.json']), - 'utf-8' - ) - ); - for (const project of projects) { - const { numberOfLessons } = await pluginEvents.getProjectMeta( - project.dashedName - ); - await setProjectConfig(project.dashedName, { numberOfLessons }); - } -} diff --git a/.freeCodeCamp/tooling/t.js b/.freeCodeCamp/tooling/t.js deleted file mode 100644 index 1cea1fdb..00000000 --- a/.freeCodeCamp/tooling/t.js +++ /dev/null @@ -1,28 +0,0 @@ -import { join } from 'path'; -import { getConfig, getState } from './env.js'; -import { ROOT } from './env.js'; - -export async function t(key, args = {}, forceLangToUse) { - const { locale: loc } = await getState(); - // Get key from ./locales/{locale}/comments.json - // Read file and parse JSON - const locale = forceLangToUse ?? loc; - const config = await getConfig(); - const assertions = config.curriculum?.assertions?.[locale]; - if (!assertions) { - return key; - } - const comments = await import(join(ROOT, assertions), { - assert: { type: 'json' } - }); - - // Get value from JSON - const value = comments.default[key]; - // Replace placeholders in value with args - const result = - Object.values(args)?.length > 0 - ? value.replace(/\{\{(\w+)\}\}/g, (_, m) => args[m]) - : value; - // Return value - return result; -} diff --git a/.freeCodeCamp/tooling/tests/main.js b/.freeCodeCamp/tooling/tests/main.js deleted file mode 100644 index db7c135c..00000000 --- a/.freeCodeCamp/tooling/tests/main.js +++ /dev/null @@ -1,326 +0,0 @@ -import { assert, AssertionError, expect, config as chaiConfig } from 'chai'; -import { watcher } from '../hot-reload.js'; -import { logover } from '../logger.js'; - -import { getProjectConfig, getState, setProjectConfig } from '../env.js'; -import { freeCodeCampConfig, ROOT } from '../env.js'; -import { - updateTest, - updateTests, - updateConsole, - updateHints, - resetBottomPanel, - handleProjectFinish -} from '../client-socks.js'; -import { runLesson } from '../lesson.js'; -import { join } from 'node:path'; -import { Worker } from 'node:worker_threads'; -import { pluginEvents } from '../../plugin/index.js'; -import { t } from '../t.js'; - -try { - const plugins = freeCodeCampConfig.tooling?.plugins; - if (plugins) { - await import(join(ROOT, plugins)); - } -} catch (e) { - logover.error('Error importing plugins:'); - logover.error(e); -} - -/** @type {Worker[]} */ -export const WORKER_POOL = []; - -/** - * Run the given project's tests - * @param {WebSocket} ws - * @param {string} projectDashedName - */ -export async function runTests(ws, projectDashedName) { - // TODO: Consider awaiting in parallel, since both invoke `fs` - const project = await getProjectConfig(projectDashedName); - const { locale } = await getState(); - // toggleLoaderAnimation(ws); - const lessonNumber = project.currentLesson; - - let testsState = []; - try { - const lesson = await pluginEvents.getLesson( - projectDashedName, - lessonNumber - ); - const { beforeAll, beforeEach, afterAll, afterEach, hints, tests } = lesson; - - if (beforeAll) { - try { - logover.debug('Starting: --before-all-- hook'); - await eval(`(async () => {${beforeAll}})()`); - logover.debug('Finished: --before-all-- hook'); - } catch (e) { - logover.error('--before-all-- hook failed to run:'); - logover.error(e); - } - } - // toggleLoaderAnimation(ws); - - testsState = tests.map((text, i) => { - return { - passed: false, - testText: text[0], - testId: i, - isLoading: !project.blockingTests - }; - }); - - await pluginEvents.onTestsStart(project, testsState); - - updateTests(ws, testsState); - updateConsole(ws, ''); - - // Create one worker for each test if non-blocking. - // TODO: See if holding pool of workers is better. - if (project.blockingTests) { - const worker = createWorker('blocking-worker', { beforeEach, project }); - WORKER_POOL.push(worker); - - // When result is received back from worker, update the client state - worker.on('message', workerMessage); - worker.stdout.on('data', data => { - logover.debug(`Blocking Worker:`, data.toString()); - }); - worker.on('exit', async exitCode => { - worker.exited = true; - removeWorkerFromPool(worker); - await handleWorkerExit({ - ws, - exitCode, - testsState, - afterEach, - project, - hints, - lessonNumber, - afterAll - }); - }); - - for (let i = 0; i < tests.length; i++) { - const [_text, testCode] = tests[i]; - testsState[i].isLoading = true; - updateTest(ws, testsState[i]); - - worker.postMessage({ testCode, testId: i }); - } - } else { - // Run tests in parallel, and in own worker threads - for (let i = 0; i < tests.length; i++) { - const [_text, testCode] = tests[i]; - testsState[i].isLoading = true; - updateTest(ws, testsState[i]); - - const worker = createWorker(`worker-${i}`, { beforeEach, project }); - WORKER_POOL.push(worker); - - // When result is received back from worker, update the client state - worker.on('message', workerMessage); - worker.stdout.on('data', data => { - logover.debug(`Worker-${i}:`, data.toString()); - }); - worker.on('exit', async exitCode => { - worker.exited = true; - removeWorkerFromPool(worker); - await handleWorkerExit({ - ws, - exitCode, - testsState, - i, - afterEach, - project, - hints, - lessonNumber, - afterAll - }); - }); - - worker.postMessage({ testCode, testId: i }); - } - } - - async function workerMessage(message) { - const { passed, testId, error } = message; - testsState[testId].isLoading = false; - testsState[testId].passed = passed; - if (error) { - if (error.type !== 'AssertionError') { - logover.error(`Test #${testId}:`, error); - } - - if (error.message) { - const assertionTranslation = await t(error.message, {}); - error.message = assertionTranslation || error.message; - } - - const consoleError = { - ...testsState[testId], - error - }; - updateConsole(ws, consoleError); - } - updateTest(ws, testsState[testId]); - - await checkTestsCallback({ - ws, - project, - hints, - lessonNumber, - testsState, - afterAll - }); - } - } catch (e) { - logover.error('Test Error: '); - logover.error(e); - } -} - -async function checkTestsCallback({ - ws, - project, - hints, - lessonNumber, - testsState, - afterAll -}) { - const passed = testsState.every(test => test.passed); - if (passed) { - await pluginEvents.onLessonPassed(project); - - resetBottomPanel(ws); - if (project.isIntegrated || lessonNumber === project.numberOfLessons - 1) { - await pluginEvents.onProjectFinished(project); - await setProjectConfig(project.dashedName, { - completedDate: Date.now() - }); - handleProjectFinish(ws); - } else { - await setProjectConfig(project.dashedName, { - currentLesson: lessonNumber + 1 - }); - await runLesson(ws, project.dashedName); - } - } else { - await pluginEvents.onLessonFailed(project); - updateHints(ws, hints); - } - const allTestsFinished = testsState.every(test => !test.isLoading); - if (allTestsFinished) { - // Run afterAll hook - if (afterAll) { - try { - logover.debug('Starting: --after-all-- hook'); - await eval(`(async () => {${afterAll}})()`); - logover.debug('Finished: --after-all-- hook'); - } catch (e) { - logover.error('--after-all-- hook failed to run:'); - logover.error(e); - } - } - - await pluginEvents.onTestsEnd(project, testsState); - WORKER_POOL.splice(0, WORKER_POOL.length); - } -} - -/** - * NOTE: Either `handleCancelTests` or `handleWorkerExit` should update `testsState` - * @param {object} param0 - * @param {number} param0.exitCode - * @param {Array} param0.testsState - * @param {number} param0.i - * @param {string} param0.afterEach - * @param {object} param0.error - * @param {object} param0.project - * @param {Array} param0.hints - * @param {string} param0.afterAll - * @param {number} param0.lessonNumber - */ -async function handleWorkerExit({ - ws, - exitCode, - testsState, - i, - afterEach, - project, - hints, - lessonNumber, - afterAll -}) { - // If exit code == 1, worker was likely terminated - // Let client know test was cancelled - if (exitCode === 1) { - if (i !== undefined) { - testsState[i].isLoading = false; - testsState[i].passed = false; - const consoleError = { - ...testsState[i], - error: 'Tests cancelled.' - }; - updateConsole(ws, consoleError); - } else { - testsState.forEach(test => { - if (test.isLoading) { - test.isLoading = false; - test.passed = false; - updateConsole(ws, { - ...test, - error: 'Tests cancelled.' - }); - } - }); - } - updateTests(ws, testsState); - } - // Run afterEach even if tests are cancelled - try { - const _afterEachOut = await eval(`(async () => { ${afterEach} })();`); - } catch (e) { - logover.error('--after-each-- hook failed to run:'); - logover.error(e); - } - // Run afterAll even if tests are cancelled - await checkTestsCallback({ - ws, - project, - hints, - lessonNumber, - testsState, - afterAll - }); -} - -function createWorker(name, workerData) { - return new Worker( - join( - ROOT, - 'node_modules/@freecodecamp/freecodecamp-os', - '.freeCodeCamp/tooling/tests', - 'test-worker.js' - ), - { - name, - workerData, - stdout: true, - stdin: true - } - ); -} - -export function getWorkerPool() { - return WORKER_POOL; -} - -function removeWorkerFromPool(worker) { - const index = WORKER_POOL.indexOf(worker); - if (index > -1) { - WORKER_POOL.splice(index, 1); - } -} diff --git a/.freeCodeCamp/tooling/tests/test-worker.js b/.freeCodeCamp/tooling/tests/test-worker.js deleted file mode 100644 index 27a121c7..00000000 --- a/.freeCodeCamp/tooling/tests/test-worker.js +++ /dev/null @@ -1,39 +0,0 @@ -import { parentPort, workerData } from 'node:worker_threads'; -// These are used in the local scope of the `eval` in `runTests` -import { assert, AssertionError, expect, config as chaiConfig } from 'chai'; -import __helpers_c from '../test-utils.js'; - -import { freeCodeCampConfig, ROOT } from '../env.js'; -import { join } from 'path'; -import { logover } from '../logger.js'; - -let __helpers = __helpers_c; - -// Update __helpers with dynamic utils: -const helpers = freeCodeCampConfig.tooling?.['helpers']; -if (helpers) { - const dynamicHelpers = await import(join(ROOT, helpers)); - __helpers = { ...__helpers_c, ...dynamicHelpers }; -} - -const { beforeEach = '', project } = workerData; - -parentPort.on('message', async ({ testCode, testId }) => { - let passed = false; - let error = null; - try { - const _eval_out = await eval(`(async () => { - ${beforeEach} - ${testCode} -})();`); - passed = true; - } catch (e) { - error = {}; - Object.getOwnPropertyNames(e).forEach(key => { - error[key] = e[key]; - }); - // Cannot pass `e` "as is", because classes cannot be passed between threads - error.type = e instanceof AssertionError ? 'AssertionError' : 'Error'; - } - parentPort.postMessage({ passed, testId, error }); -}); diff --git a/.freeCodeCamp/tooling/utils.js b/.freeCodeCamp/tooling/utils.js deleted file mode 100644 index d991bb91..00000000 --- a/.freeCodeCamp/tooling/utils.js +++ /dev/null @@ -1,124 +0,0 @@ -import { cp, readdir, rm, rmdir, writeFile, readFile } from 'fs/promises'; -import path, { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; -import { promisify } from 'util'; -import { exec } from 'child_process'; -import { readdirSync } from 'fs'; -import { ROOT } from './env.js'; -const execute = promisify(exec); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// Adds all existing paths at runtime -const PERMANENT_PATHS_IN_ROOT = readdirSync('..'); - -/** - * Alter the `.vscode/settings.json` file properties - * @param {object} obj Object Literal to set/overwrite properties - */ -export async function setVSCSettings(obj) { - const pathToSettings = join(ROOT, '.vscode', 'settings.json'); - const settings = await getVSCSettings(); - const updated = { - ...settings, - ...obj - }; - await writeFile(pathToSettings, JSON.stringify(updated, null, 2)); -} - -/** - * Get the `.vscode/settings.json` file properties - * @returns The contents of the `.vscode/settings.json` file - */ -export async function getVSCSettings() { - const pathToSettings = join(ROOT, '.vscode', 'settings.json'); - return JSON.parse(await readFile(pathToSettings, 'utf8')); -} - -/** - * Toggle `[file]: true` to `.vscode/settings.json` file - * @param {string} file Filename of file to hide in VSCode settings - */ -export async function hideFile(file) { - // Get `files.exclude` - const filesExclude = (await getVSCSettings())['files.exclude']; - filesExclude[file] = true; - await setVSCSettings({ 'files.exclude': filesExclude }); -} - -/** - * Toggle `[file]: false` to `.vscode/settings.json` file - * @param {string} file Filename of file to show in VSCode settings - */ -export async function showFile(file) { - // Get `files.exclude` - const filesExclude = (await getVSCSettings())['files.exclude']; - filesExclude[file] = false; - await setVSCSettings({ 'files.exclude': filesExclude }); -} - -/** - * Hide all files in the `files.exclude` property of the `.vscode/settings.json` file - */ -export async function hideAll() { - const filesExclude = (await getVSCSettings())['files.exclude']; - for (const file of Object.keys(filesExclude)) { - filesExclude[file] = true; - } - await setVSCSettings({ 'files.exclude': filesExclude }); -} - -/** - * Show all files in the `files.exclude` property of the `.vscode/settings.json` file - */ -export async function showAll() { - const filesExclude = (await getVSCSettings())['files.exclude']; - for (const file of Object.keys(filesExclude)) { - filesExclude[file] = false; - } - await setVSCSettings({ 'files.exclude': filesExclude }); -} - -/** - * Copies all paths in the given `project.dashedName` directory to the root directory - * @param {object} project Project to reset - */ -export async function dumpProjectDirectoryIntoRoot(project) { - await cp(join(ROOT, project.dashedName), ROOT, { - recursive: true - }); -} - -/** - * Removes non-boilerplate paths from the root, and copies them to the project directory - * @param {object} projectToCopyTo Project to copy to - */ -export async function cleanWorkingDirectory(projectToCopyTo) { - if (projectToCopyTo) { - await copyNonWDirToProject(projectToCopyTo); - } - const allOtherPaths = (await readdir(ROOT)).filter( - p => !PERMANENT_PATHS_IN_ROOT.includes(p) - ); - allOtherPaths.forEach(async p => { - await rm(join(ROOT, p), { recursive: true }); - }); -} - -/** - * Copies all non-boilerplate paths from the root to the project directory - * @param {object} project Project to copy to - */ -async function copyNonWDirToProject(project) { - const allOtherPaths = (await readdir(ROOT)).filter( - p => !PERMANENT_PATHS_IN_ROOT.includes(p) - ); - allOtherPaths.forEach(async p => { - const relativePath = join(ROOT, p); - await cp(relativePath, join(ROOT, project, p), { - recursive: true, - force: true - }); - }); -} diff --git a/.freeCodeCamp/tooling/validate.js b/.freeCodeCamp/tooling/validate.js deleted file mode 100644 index daa41b90..00000000 --- a/.freeCodeCamp/tooling/validate.js +++ /dev/null @@ -1,526 +0,0 @@ -import { join } from 'path'; -import { readdir, access, constants, readFile } from 'fs/promises'; -import { freeCodeCampConfig, getProjectConfig, ROOT } from './env.js'; -import { logover } from './logger.js'; -import { pluginEvents } from '../plugin/index.js'; - -const CURRICULUM_PATH = join( - ROOT, - freeCodeCampConfig.curriculum.locales.english -); -const CONFIG_PATH = join(ROOT, freeCodeCampConfig.config['projects.json']); - -/** - * # Validate Curriculum - * - * The validation throws when it encounters something that could break the functionality of the server, - * and logs a warning for anti-patterns that do not cause panics. - * - * ## Errors - * - * ### Projects - * - * - Each project has an H1 heading - * - Each project has a project description - * - Each project is associated with a boilerplate - * - Each project has congruent lesson numbers - * - First lesson is 0 - * - Each project ends in `--fcc-end--` - * - * ### Configs - * - * - All configs have `dashedName` and `id` fields - * - All configs have a matching project by the `dashedName` field - * - Any `currentLesson` field is >= 0 - * - Any `currentLesson` field is < the number of lessons in the project - * - Any `isIntegrated` field is a boolean - * - Any `isPublic` field is a boolean - * - Any `runTestsOnWatch` field is a boolean - * - Any `isResetEnabled` field is a boolean - * - Any `seedEveryLesson` field is a boolean - * - Any `blockingTests` field is a boolean - * - Any `breakOnFailure` filed is a boolean - * - * ### Seeds - * - * - All seed lesson numbers are >= 0 - * - * ## Warnings - * - * ### Projects - * - * - Each project lesson has a `--description--` heading - * - Each description is not empty - * - Each project lesson has a `--tests--` heading - * - Each project lesson tests section is not empty - * - * ### Configs - * - * - Each config has a `description` field - * - Each config has a `title` field - * - If `breakOnFailure` is true, `blockingTests` is also true - * - * ### Seeds - * - * - Seed already exists in lesson - * - No seed lesson number is greater than the number of lessons in the project - 1 - * - Lesson numbers are ordered - */ -export async function validateCurriculum() { - const { version, config, curriculum } = freeCodeCampConfig; - if (!isSemver(version)) { - panic( - 'Invalid `version` field in `freecodecamp.conf.json`', - version, - '`version` should be a semver string`' - ); - } - if (!config?.['projects.json']) { - panic( - 'Invalid `config["projects.json"]` field in `freecodecamp.conf.json`', - config['projects.json'], - '`projects.json` should be a string' - ); - } - if (!config?.['state.json']) { - panic( - 'Invalid `config["state.json"]` field in `freecodecamp.conf.json`', - config['state.json'], - '`state.json` should be a string' - ); - } - if (!curriculum?.['locales']) { - panic( - 'Invalid `curriculum["locales"]` field in `freecodecamp.conf.json`', - curriculum['locales'], - '`locales` should be an object' - ); - } - if (!curriculum?.['locales']?.english) { - panic( - 'Invalid `curriculum["locales"]["english"]` field in `freecodecamp.conf.json`', - curriculum['locales']['english'], - '`english` is required, and should be a string' - ); - } - - const { port, client, hotReload, tooling } = freeCodeCampConfig; - // `port` must be a u16 - if ( - port && - (typeof port !== 'number' || port < 0 || port > 65535 || port % 1 !== 0) - ) { - panic( - 'Invalid `port` field in `freecodecamp.conf.json`', - port, - '`port` should be a u16' - ); - } - if (client) { - const { assets, landing, static: stat } = client; - if (assets) { - const { header, favicon } = assets; - if (header && typeof header !== 'string') { - panic( - 'Invalid `client.assets.header` field in `freecodecamp.conf.json`', - header, - '`header` should be a string' - ); - } - if (favicon && typeof favicon !== 'string') { - panic( - 'Invalid `client.assets.favicon` field in `freecodecamp.conf.json`', - favicon, - '`favicon` should be a string' - ); - } - } - if (landing) { - for (const [key, value] of Object.entries(landing)) { - const { title, description, 'faq-link': link, 'faq-text': faq } = value; - if (title && typeof title !== 'string') { - panic( - `Invalid \`client.landing.${key}.title\` field in \`freecodecamp.conf.json\``, - title, - `\`title\` should be a string` - ); - } - if (description && typeof description !== 'string') { - panic( - `Invalid \`client.landing.${key}.description\` field in \`freecodecamp.conf.json\``, - description, - `\`description\` should be a string` - ); - } - if (link && typeof link !== 'string') { - panic( - `Invalid \`client.landing.${key}['faq-link']\` field in \`freecodecamp.conf.json\``, - link, - `\`faq-link\` should be a string` - ); - } - if (faq && typeof faq !== 'string') { - panic( - `Invalid \`client.landing.${key}['faq-text']\` field in \`freecodecamp.conf.json\``, - faq, - `\`faq-text\` should be a string` - ); - } - } - } - if (stat) { - if (typeof stat === 'string') { - if (typeof stat !== 'string') { - panic( - 'Invalid `client.static` field in `freecodecamp.conf.json`', - stat, - '`static` should be a string or object' - ); - } - } else if (typeof stat === 'object') { - for (const [route, dir] of Object.entries(stat)) { - if (typeof dir !== 'string') { - panic( - `Invalid \`client.static[${route}]\` field in \`freecodecamp.conf.json\``, - dir, - `static directory should be a string` - ); - } - } - } - } - } - - if (hotReload) { - const { ignore } = hotReload; - if (ignore) { - if (Array.isArray(hotReload.ignore)) { - for (const ignore of hotReload.ignore) { - if (typeof ignore !== 'string') { - panic( - 'Invalid `hotReload.ignore` field in `freecodecamp.conf.json`', - ignore, - '`ignore` should be an array of strings' - ); - } - } - } else { - panic( - 'Invalid `hotReload.ignore` field in `freecodecamp.conf.json`', - hotReload.ignore, - '`ignore` should be an array of strings' - ); - } - } - } - - if (tooling) { - const { helpers, plugins } = tooling; - if (helpers && typeof helpers !== 'string') { - panic( - 'Invalid `tooling.helpers` field in `freecodecamp.conf.json`', - helpers, - '`helpers` should be a string' - ); - } - if (plugins && typeof plugins !== 'string') { - panic( - 'Invalid `tooling.plugins` field in `freecodecamp.conf.json`', - plugins, - '`plugins` should be a string' - ); - } - } - - const projects = JSON.parse( - await readFile( - join(ROOT, freeCodeCampConfig.config['projects.json']), - 'utf-8' - ) - ); - for (const projectConfig of projects) { - const { dashedName, id } = projectConfig; - if (!dashedName) { - panic( - 'Invalid `dashedName` field in `projects.json`', - dashedName, - '`dashedName` is required' - ); - } - if ( - typeof port !== 'number' || - port < 0 || - port > 65535 || - port % 1 !== 0 - ) { - panic('Invalid `id` field in `projects.json`', id, '`id` is required'); - } - const projectPath = join(ROOT, dashedName); - try { - await access(projectPath, constants.F_OK); - } catch (e) { - panic( - `Project ${dashedName} does not have a directory in the workspace root`, - projectPath, - `Project should have a matching directory in the workspace root` - ); - } - - const { - isIntegrated, - isPublic, - runTestsOnWatch, - isResetEnabled, - seedEveryLesson, - blockingTests, - breakOnFailure - } = projectConfig; - if (!undeBoolNull(isIntegrated)) { - panic( - 'Invalid `isIntegrated` field in `projects.json`', - isIntegrated, - '`isIntegrated` should be a boolean' - ); - } - if (!undeBoolNull(isPublic)) { - panic( - 'Invalid `isPublic` field in `projects.json`', - isPublic, - '`isPublic` should be a boolean' - ); - } - if (!undeBoolNull(runTestsOnWatch)) { - panic( - 'Invalid `runTestsOnWatch` field in `projects.json`', - runTestsOnWatch, - '`runTestsOnWatch` should be a boolean' - ); - } - if (!undeBoolNull(isResetEnabled)) { - panic( - 'Invalid `isResetEnabled` field in `projects.json`', - isResetEnabled, - '`isResetEnabled` should be a boolean' - ); - } - if (!undeBoolNull(seedEveryLesson)) { - panic( - 'Invalid `seedEveryLesson` field in `projects.json`', - seedEveryLesson, - '`seedEveryLesson` should be a boolean' - ); - } - if (!undeBoolNull(blockingTests)) { - panic( - 'Invalid `blockingTests` field in `projects.json`', - blockingTests, - '`blockingTests` should be a boolean' - ); - } - if (!undeBoolNull(breakOnFailure)) { - panic( - 'Invalid `breakOnFailure` field in `projects.json`', - breakOnFailure, - '`breakOnFailure` should be a boolean' - ); - } - - const projectMeta = await pluginEvents.getProjectMeta(dashedName); - const { title, description, tags = [], numberOfLessons } = projectMeta; - if (!title || typeof title !== 'string') { - panic( - 'Invalid `title` field in `projects.json`', - title, - '`title` is required' - ); - } - if (!description || typeof description !== 'string') { - panic( - 'Invalid `description` field in `projects.json`', - description, - '`description` is required' - ); - } - if (tags.length) { - for (const tag of tags) { - if (typeof tag !== 'string') { - panic( - 'Invalid `tags` field in `projects.json`', - tag, - '`tags` should be an array of strings' - ); - } - } - } - if (numberOfLessons < 1) { - panic( - 'Invalid `numberOfLessons` field in `projects.json`', - numberOfLessons, - '`numberOfLessons` should be a positive integer' - ); - } - - for (let i = 0; i < numberOfLessons; i++) { - const lesson = await pluginEvents.getLesson(dashedName, i); - const { - description, - tests, - hints, - seed, - beforeAll, - afterAll, - beforeEach, - afterEach, - isForce, - meta - } = lesson; - if (!description) { - warn( - `Missing \`--description--\` heading in lesson ${i} of ${dashedName}`, - description, - 'Lesson description should not be empty' - ); - } - if (!tests.length) { - warn( - `Missing \`--tests--\` heading in lesson ${i} of ${dashedName}`, - tests, - 'Lesson tests should not be empty' - ); - } else { - for (const test of tests) { - if (test.length !== 2) { - panic( - `Invalid test in lesson ${i} of ${dashedName}`, - test, - 'Test should be an array of two strings' - ); - } else { - const [testText, testCode] = test; - if (typeof testText !== 'string') { - panic( - `Invalid test text in lesson ${i} of ${dashedName}`, - testText, - 'Test text should be a string' - ); - } - if (typeof testCode !== 'string') { - panic( - `Invalid test in lesson ${i} of ${dashedName}`, - testCode, - 'Test should be a string' - ); - } - } - } - } - if (seed) { - if (Array.isArray(seed)) { - for (const s of seed) { - if ( - typeof s !== 'string' && - typeof s.filePath !== 'string' && - typeof s.fileSeed !== 'string' - ) { - panic( - `Invalid seed in lesson ${i} of ${dashedName}`, - s, - 'Seed should be a string or an object with `filePath` and `fileSeed`' - ); - } - } - } else { - panic( - `Invalid seed in lesson ${i} of ${dashedName}`, - seed, - 'Seed should be a an array of strings' - ); - } - } - if (!undeBoolNull(isForce)) { - panic( - `Invalid isForce in lesson ${i} of ${dashedName}`, - isForce, - 'isForce should be a boolean' - ); - } - if (hints) { - if (Array.isArray(hints)) { - for (const hint of hints) { - if (typeof hint !== 'string') { - panic( - `Invalid hint in lesson ${i} of ${dashedName}`, - hint, - 'Hint should be a string' - ); - } - } - } else { - panic( - `Invalid hints in lesson ${i} of ${dashedName}`, - hints, - 'Hints should be an array of strings' - ); - } - } - if (beforeAll && typeof beforeAll !== 'string') { - panic( - `Invalid beforeAll in lesson ${i} of ${dashedName}`, - beforeAll, - 'beforeAll should be a string' - ); - } - if (afterAll && typeof afterAll !== 'string') { - panic( - `Invalid afterAll in lesson ${i} of ${dashedName}`, - afterAll, - 'afterAll should be a string' - ); - } - if (beforeEach && typeof beforeEach !== 'string') { - panic( - `Invalid beforeEach in lesson ${i} of ${dashedName}`, - beforeEach, - 'beforeEach should be a string' - ); - } - if (afterEach && typeof afterEach !== 'string') { - panic( - `Invalid afterEach in lesson ${i} of ${dashedName}`, - afterEach, - 'afterEach should be a string' - ); - } - if (meta?.watch && meta?.ignore) { - panic( - `Invalid meta in lesson ${i} of ${dashedName}`, - meta, - 'Lesson should not have both `watch` and `ignore`' - ); - } - } - } - - logover.info('All curriculum files are valid'); -} - -function undeBoolNull(val) { - return val === undefined || val === null || typeof val === 'boolean'; -} - -function isSemver(val) { - return /^\d+\.\d+\.\d+$/.test(val); -} - -function panic(message, value, expectation) { - logover.error(message); - console.log('Expected:', expectation); - console.log('Received:', value); - throw new Error(message); -} - -function warn(message, value, expectation) { - logover.warn(message); - console.log('Expected:', expectation); - console.log('Received:', value); -} diff --git a/.freeCodeCamp/tsconfig.json b/.freeCodeCamp/tsconfig.json deleted file mode 100644 index 84dc722a..00000000 --- a/.freeCodeCamp/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "outDir": "./dist/", - // "noImplicitAny": true, - "sourceMap": true, - "jsx": "react-jsx", - "allowJs": true, - "moduleResolution": "node", - "lib": ["WebWorker", "DOM", "DOM.Iterable", "ES2015"], - "target": "es5", - "module": "esnext", - "strict": true, - "esModuleInterop": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "resolveJsonModule": true, - // "skipLibCheck": true, - "types": ["node"] - }, - "exclude": ["node_modules", "**/*.spec.ts"], - "include": ["client/**/*"] -} diff --git a/.freeCodeCamp/webpack.config.cjs b/.freeCodeCamp/webpack.config.cjs deleted file mode 100644 index 5d350484..00000000 --- a/.freeCodeCamp/webpack.config.cjs +++ /dev/null @@ -1,82 +0,0 @@ -const path = require('path'); -const HtmlWebpackPlugin = require('html-webpack-plugin'); -module.exports = { - entry: path.join(__dirname, 'client/index.tsx'), - devtool: 'inline-source-map', - mode: process.env.NODE_ENV || 'development', - devServer: { - compress: true, - port: 9000 - }, - watch: process.env.NODE_ENV === 'development', - watchOptions: { - ignored: ['**/node_modules', '**/config'] - }, - module: { - rules: [ - { - test: /\.(js|jsx|tsx|ts)$/, - use: { - loader: 'babel-loader', - options: { - presets: ['@babel/preset-env'], - plugins: [ - require.resolve('@babel/plugin-syntax-import-assertions'), - [ - 'prismjs', - { - languages: [ - 'javascript', - 'css', - 'html', - 'json', - 'markdown', - 'sql', - 'rust', - 'typescript', - 'jsx', - 'c', - 'csharp', - 'cpp', - 'dotnet', - 'python', - 'pug', - 'handlebars' - ], - plugins: [], - theme: 'okaidia', - css: true - } - ] - ] - } - } - }, - { - test: /\.(ts|tsx)$/, - use: ['ts-loader'] - }, - { - test: /\.(css|scss)$/, - use: ['style-loader', 'css-loader'] - }, - { - test: /\.(jpg|jpeg|png|gif|mp3|svg)$/, - type: 'asset/resource' - } - ] - }, - resolve: { - extensions: ['.tsx', '.ts', '.js'] - }, - output: { - filename: 'bundle.js', - path: path.resolve(__dirname, 'dist') - }, - plugins: [ - new HtmlWebpackPlugin({ - template: path.join(__dirname, 'client', 'index.html'), - favicon: path.join(__dirname, 'client', 'assets/fcc_primary_small.svg') - }) - ] -}; diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index d0dc11db..ae050cbd 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -41,7 +41,7 @@ jobs: with: # (required) Comma-separated list of binary names (non-extension portion of filename) to build and upload. # Note that glob pattern is not supported yet. - bin: create-freecodecamp-os-app + bin: freecodecamp-os-cli manifest-path: ./cli/Cargo.toml target: ${{ matrix.target }} # (required) GitHub token for uploading assets to GitHub Releases. diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index cd83baa7..2a28f6c4 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -21,7 +21,7 @@ jobs: ADMONISH_VERSION: 1.15.0 UNLINK_VERSION: 0.1.0 steps: - - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install mdBook run: | mkdir bin diff --git a/.github/workflows/mdbook.yml b/.github/workflows/mdbook.yml index 2b9bb894..08989f37 100644 --- a/.github/workflows/mdbook.yml +++ b/.github/workflows/mdbook.yml @@ -29,7 +29,7 @@ jobs: env: MDBOOK_VERSION: 0.4.36 steps: - - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 + - uses: actions/checkout@6 - name: Install mdBook run: | curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf -y | sh @@ -38,7 +38,7 @@ jobs: cargo install --version ^1 mdbook-admonish - name: Setup Pages id: pages - uses: actions/configure-pages@b8130d9ab958b325bbde9786d62f2c97a9885a0e # v3 + uses: actions/configure-pages@5 - name: Build with mdBook run: cd docs && mdbook build - name: Check Expected Files @@ -48,7 +48,7 @@ jobs: exit 1 fi - name: Upload artifact - uses: actions/upload-pages-artifact@84bb4cd4b733d5c320c9c9cfbc354937524f4d64 # v1 + uses: actions/upload-pages-artifact@4 with: path: ./docs/book/html @@ -62,4 +62,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@de14547edc9944350dc0481aa5b7afb08e75f254 # v2 + uses: actions/deploy-pages@4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9263afc8..accb50d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,25 +1,53 @@ name: Publish to npm +permissions: + contents: write + on: - # Allows you to run this workflow manually from the Actions tab + release: + types: [created] workflow_dispatch: jobs: publish: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - - name: Publish to NPM - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4 + - name: Set up Rust + uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # stable with: - node-version: 20 - registry-url: 'https://registry.npmjs.org' - - run: npm ci + targets: x86_64-unknown-linux-gnu + + - name: Build client + run: bun install && bun run build:client + + - name: Build server binary + run: cargo build --release --bin server --target x86_64-unknown-linux-gnu + + - name: Rename binary + run: cp target/x86_64-unknown-linux-gnu/release/server freecodecamp-server-x86_64-unknown-linux-gnu - - name: Publish to NPM + - name: Upload binary to release + if: github.event_name == 'release' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npm publish + gh release upload "${{ github.ref_name }}" \ + freecodecamp-server-x86_64-unknown-linux-gnu \ + --clobber + + - name: Set up Node + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24 + registry-url: 'https://registry.npmjs.org' + + - name: Publish to npm + run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} diff --git a/.gitignore b/.gitignore index e747329b..599bbb4b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ Cargo.lock .freeCodeCamp/dist .DS_Store /docs/book -self/.logs/ \ No newline at end of file +self/.logs/ +client/dist/ \ No newline at end of file diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 372c5432..00000000 --- a/.gitpod.yml +++ /dev/null @@ -1,17 +0,0 @@ -image: - file: Dockerfile - -# Commands to start on workspace startup -tasks: - - init: npm ci - - command: node tooling/adjust-url.js - -ports: - - port: 8080 - onOpen: open-preview - -# TODO: See about publishing to Open VSX for smoother process -vscode: - extensions: - - https://github.com/freeCodeCamp/courses-vscode-extension/releases/download/v3.0.0/freecodecamp-courses-3.0.0.vsix - - https://github.com/freeCodeCamp/freecodecamp-dark-vscode-theme/releases/download/v1.0.0/freecodecamp-dark-vscode-theme-1.0.0.vsix diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 76c9db69..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "rust-analyzer.linkedProjects": ["./cli/Cargo.toml"] -} diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..5ed9a5d7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +members = ["config", "parser", "runner", "server", "cli"] +resolver = "3" + +[workspace.package] +version = "4.0.0" +edition = "2024" +license = "BSD-3-Clause" +repository = "https://github.com/freeCodeCamp/freeCodeCampOS" +homepage = "https://opensource.freecodecamp.org/freeCodeCampOS/" + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/Dockerfile b/Dockerfile index c6bbe00d..9c15bf86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,43 @@ -FROM gitpod/workspace-full:2024-01-17-19-15-31 +FROM rust:latest AS builder -WORKDIR /workspace/freeCodeCampOS +WORKDIR /app -COPY --chown=gitpod:gitpod . . +# Copy Cargo files +COPY Cargo.* ./ +COPY config config +COPY parser parser +COPY runner runner +COPY server server +COPY cli cli + +# Build release binaries +RUN cargo build --release --bin server + +# Build stage for client +FROM node:24-alpine AS client-builder + +WORKDIR /app/client + +COPY client/package.json client/bun.lockb* ./ +COPY client . ./ + +RUN npm install -g bun && \ + bun install && \ + bun run build + +# Final stage +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y ca-certificates nodejs && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy binaries from builder +COPY --from=builder /app/target/release/server ./freecodecamp-server +COPY --from=client-builder /app/client/dist ./client/dist + +EXPOSE 8080 + +ENV RUST_LOG=info + +CMD ["./freecodecamp-server"] diff --git a/Dockerfile.migration b/Dockerfile.migration new file mode 100644 index 00000000..d2d2172d --- /dev/null +++ b/Dockerfile.migration @@ -0,0 +1,43 @@ +FROM rust:latest as builder + +WORKDIR /app + +# Copy Cargo files +COPY Cargo.* ./ +COPY config config +COPY parser parser +COPY runner runner +COPY server server +COPY cli cli + +# Build release binaries +RUN cargo build --release --bin freecodecamp-server + +# Build stage for client +FROM node:24-alpine as client-builder + +WORKDIR /app/client + +COPY client/package.json client/bun.lockb* ./ +COPY client . ./ + +RUN npm install -g bun && \ + bun install && \ + bun run build + +# Final stage +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y ca-certificates nodejs && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy binaries from builder +COPY --from=builder /app/target/release/freecodecamp-server ./ +COPY --from=client-builder /app/client/dist ./client/dist + +EXPOSE 8080 + +ENV RUST_LOG=info + +CMD ["./freecodecamp-server"] diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..c917994c --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,261 @@ +# Migration Guide: v3 → v4 + +freeCodeCampOS v4 is a complete rewrite of the platform from Node.js to Rust. This document covers breaking changes, missing features, and a step-by-step guide for upgrading existing courses. + +## Overview of Changes + +| Area | v3 | v4 | +|------|----|----| +| Server | Node.js + Express | Rust + Axum | +| Build tool | Webpack | Vite 7 | +| Frontend | React 18 | React 19 | +| Distribution | npm package | Single binary | +| Config style | `camelCase` | `snake_case` | +| Project IDs | Integer | UUID | + +--- + +## `freecodecamp.conf.json` Changes + +### Renamed keys + +| v3 | v4 | +|----|-----| +| `hotReload` | `hot_reload` | +| `client.static` | `client.static_paths` | +| `landing..faq-link` | `landing..faq_link` | +| `landing..faq-text` | `landing..faq_text` | + +### Before (v3) + +```json +{ + "version": "0.1.0", + "port": 8080, + "client": { + "static": { + "/images": "./curriculum/images", + "/script/injectable.js": "./client/injectable.js" + }, + "landing": { + "english": { + "title": "My Course", + "description": "...", + "faq-link": "https://example.com", + "faq-text": "FAQ" + } + } + }, + "hotReload": { + "ignore": [".logs/.temp.log", "config/"] + }, + "tooling": { + "helpers": "./tooling/helpers.js", + "plugins": "./tooling/plugins.js" + } +} +``` + +### After (v4) + +```json +{ + "version": "4.0.0", + "port": 8080, + "client": { + "static_paths": { + "/images": "./curriculum/images", + "/script/injectable.js": "./client/injectable.js" + }, + "landing": { + "english": { + "title": "My Course", + "description": "...", + "faq_link": "https://example.com", + "faq_text": "FAQ" + } + } + }, + "hot_reload": { + "ignore": [".logs/.temp.log", "config/"] + }, + "tooling": { + "helpers": "./tooling/helpers.js", + "plugins": "./tooling/plugins.js" + } +} +``` + +--- + +## `projects.json` Changes + +All field names changed from `camelCase` to `snake_case`. Project `id` changed from an integer to a UUID string. A `title` and `order` field are now required. + +### Renamed/changed fields + +| v3 | v4 | +|----|----| +| `id` (integer) | `id` (UUID string, e.g. `"a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"`) | +| `dashedName` | `dashed_name` | +| `isIntegrated` | `is_integrated` | +| `isPublic` | `is_public` | +| `runTestsOnWatch` | `run_tests_on_watch` | +| `seedEveryLesson` | `seed_every_lesson` | +| `isResetEnabled` | `is_reset_enabled` | +| `numberOfLessons` | `number_of_lessons` (now auto-calculated on startup) | +| `blockingTests` | `blocking_tests` | +| `breakOnFailure` | `break_on_failure` | +| `currentLesson` (tracked here) | removed — now tracked in `state.json` | +| _(not present)_ | `title` (required) | +| _(not present)_ | `order` (required, for display ordering) | +| _(not present)_ | `tags` (optional, string array) | + +### Before (v3) + +```json +[ + { + "id": 0, + "dashedName": "learn-x-by-building-y", + "isIntegrated": false, + "isPublic": true, + "currentLesson": 0, + "runTestsOnWatch": true, + "seedEveryLesson": false, + "isResetEnabled": true, + "numberOfLessons": 10, + "blockingTests": false, + "breakOnFailure": false + } +] +``` + +### After (v4) + +```json +[ + { + "id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", + "title": "Learn X by Building Y", + "dashed_name": "learn-x-by-building-y", + "order": 0, + "is_integrated": false, + "is_public": true, + "run_tests_on_watch": true, + "seed_every_lesson": false, + "is_reset_enabled": true, + "blocking_tests": false, + "break_on_failure": false + } +] +``` + +--- + +## Curriculum Markdown Changes + +### Test code blocks require `runner=` + +In v3, all test code blocks were assumed to run with Node.js. In v4, the runner must be specified explicitly using the code block language tag. + +**v3:** + +````markdown +```js +assert(true); +``` +```` + +**v4:** + +````markdown +```js,runner=node +assert(true); +``` +```` + +Available runners: `node`, `bash`. + +--- + +## Installation Changes + +freeCodeCampOS is still distributed as an npm package: + +```bash +npm install @freecodecamp/freecodecamp-os +``` + +In v4, the package downloads a pre-built Rust binary (`freecodecamp-server`) instead of running a Node.js server. The server is started from the root of the course directory: + +```bash +npx freecodecamp-server +``` + +The CLI tool is installed separately: + +```bash +npm install -g create-freecodecamp-os-app +create-freecodecamp-os-app create +``` + +--- + +## Test Helper Changes + +The v3 test environment included `__helpers` utilities from the `utils.js` module (e.g. `setVSCSettings`, `hideFile`, `showFile`, `cleanWorkingDirectory`). These are **not available** in v4. + +The v4 test environment provides a smaller, focused set of built-in globals. See the [Globals](./docs/src/testing/globals.md) documentation for what is available. + +--- + +## Missing Features in v4 + +The following features from v3 are **not yet implemented** in v4: + +### Plugin system + +The plugin event hooks (`onTestsStart`, `onTestsEnd`, `onProjectStart`, `onProjectFinished`, `onLessonPassed`, `onLessonFailed`, `onLessonLoad`) and the custom parser API (`getProjectMeta`, `getLesson`) are not implemented. The `tooling.plugins` config key is accepted but has no effect. + +See [plugin-system.md](./docs/src/plugin-system.md) for the planned API. + +### Python runner + +The `python` runner is documented but not yet implemented. Only `node` and `bash` runners are available. + +### Git-based curriculum building + +The v3 `gitterizer` tool, which built git history from curriculum markdown, has been removed and has no equivalent in v4. + +### VSCode helper utilities + +The v3 `__helpers` object with VSCode-integration utilities (e.g. `hideFile`, `showFile`, `setVSCSettings`) has been removed and is not available in v4. + +--- + +## Step-by-Step Migration + +1. **Update `freecodecamp.conf.json`** + - Rename `hotReload` → `hot_reload` + - Rename `client.static` → `client.static_paths` + - Rename `faq-link` → `faq_link` and `faq-text` → `faq_text` in landing configs + - Update `version` to `"4.0.0"` + +2. **Update `projects.json`** + - Generate a UUID for each project (e.g. using `uuidgen` or an online generator) to replace the integer `id` + - Rename all fields from `camelCase` to `snake_case` (see table above) + - Add a `title` and `order` field to each project + - Remove `currentLesson` (it is now managed in `state.json`) + +3. **Update test code blocks** + - The `js` language tag is automatically mapped to the `node` runner, so existing `js` test blocks continue to work. Explicitly specifying `,runner=node` is supported and recommended for clarity. + +4. **Update the npm dependency version** + - Upgrade `@freecodecamp/freecodecamp-os` to v4 in `package.json` + - Remove any `postinstall` or `prepare` script that built the v3 client (the client is now embedded in the binary) + +5. **Run the server** + ```bash + npx freecodecamp-server + ``` diff --git a/README.md b/README.md index f3596970..45f68ff3 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,71 @@ # freeCodeCampOS -This package runs the environment for courses on freeCodeCamp.org. +Open source platform for creating and hosting interactive coding curricula. -See the documentation for more information: https://opensource.freecodecamp.org/freeCodeCampOS/ +See the [documentation](https://opensource.freecodecamp.org/freeCodeCampOS/) for full details. -The course content served on the client is written in Markdown files. The lesson/project tests run on the Nodejs server are written in the same Markdown files. +## Architecture -The `freecodecamp.conf.json` file is used to configure the course, and define the actions taken by the [freeCodeCamp - Courses](https://marketplace.visualstudio.com/items?itemName=freeCodeCamp.freecodecamp-courses) extension. +freeCodeCampOS v4 is a Rust workspace with an embedded React frontend: + +``` +config/ # Shared types and configuration +parser/ # Curriculum markdown parser (Comrak/GFM) +runner/ # Multi-language test execution engine +server/ # Axum HTTP + WebSocket server +client/ # React 19 + TypeScript frontend (Vite) +cli/ # create-freecodecamp-os-app CLI +example/ # Example curriculum project +docs/ # mdBook documentation +``` + +## Quick Start + +**Prerequisites:** Rust 1.93.1+, Bun 1.3.10+, Node.js 20+ + +```bash +# Build everything +bun run build + +# Run the server (from a course directory containing freecodecamp.conf.json) +./target/release/freecodecamp-server +``` + +For development: + +```bash +# Terminal 1: Rust backend +cargo run --bin server + +# Terminal 2: React client (hot reload) +bun run dev:client +``` + +## Creating a Course + +Use the CLI to scaffold a new course: + +```bash +cargo run --bin create-freecodecamp-os-app -- create +``` + +Then run the server from the course directory: + +```bash +cd my-course/ +../target/release/server +``` + +End users install freeCodeCampOS via npm (`@freecodecamp/freecodecamp-os`), which provides the pre-built binary. + +## Upgrading from v3 + +See [MIGRATION.md](./MIGRATION.md) for the full migration guide. + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md) and the [Contributing docs](./docs/src/contributing.md). + +## License + +BSD-3-Clause — see [LICENSE](./LICENSE). diff --git a/README_MIGRATION.md b/README_MIGRATION.md new file mode 100644 index 00000000..354b25e2 --- /dev/null +++ b/README_MIGRATION.md @@ -0,0 +1,195 @@ +# freeCodeCampOS - Rust Migration Complete + +A complete rewrite of the freeCodeCampOS platform from Node.js to Rust, providing a high-performance, single-binary distribution for interactive coding curricula. + +## Features + +- **High Performance**: Rust-based backend with minimal memory footprint +- **Single Binary**: Complete application distributed as a single executable +- **Modern Frontend**: React 19 with TypeScript and Vite 7 +- **Extensible Architecture**: Modular design with separate components for parsing, running, and serving +- **Multiple Runners**: Support for Node.js, Bash, Python, and extensible to other languages +- **Real-time Updates**: WebSocket support for live test execution feedback + +## Architecture + +### Components + +- **`config`** - Shared configuration types and structures +- **`parser`** - Curriculum markdown parser (GitHub Flavored Markdown) +- **`runner`** - Test execution engine with multiple language support +- **`server`** - Axum-based HTTP REST API and WebSocket server +- **`client`** - React 19 + TypeScript frontend with Vite build system +- **`cli`** - Command-line tool for curriculum creation and management +- **`example`** - Example curriculum project demonstrating all features +- **`docs`** - User documentation built with mdbook + +## Quick Start + +### Prerequisites + +- Rust 1.93.1+ ([Get Rust](https://rustup.rs/)) +- Bun 1.3.10+ ([Get Bun](https://bun.sh/)) +- Node.js 20+ (for example projects) + +### Building + +```bash +# Build everything (Rust + Client) +bun run build + +# Build just Rust components +cargo build --release + +# Build just the client +cd client && bun run build + +# Run tests +cargo test + +# Run linting +cargo fmt --all && cargo clippy --all +``` + +### Development + +```bash +# Terminal 1: Run the development server +cargo run --bin server + +# Terminal 2: Run the client in dev mode +cd client && bun run dev + +# Server will be available at http://localhost:8080 +# Client will be available at http://localhost:5173 +``` + +### Creating a New Curriculum + +```bash +# Use the CLI to create a new curriculum +./target/release/create-freecodecamp-os-app +``` + +## Project Structure + +``` +freeCodeCampOS/ +├── config/ # Shared types and configuration +├── parser/ # Markdown curriculum parser +├── runner/ # Test execution engine +├── server/ # HTTP server implementation +├── client/ # React frontend +├── cli/ # Command-line tool +├── example/ # Example curriculum +├── docs/ # User documentation +├── Cargo.toml # Rust workspace definition +├── package.json # Root scripts +└── target/release/ # Compiled binaries + ├── server # Main server binary + └── create-freecodecamp-os-app # CLI tool +``` + +## Curriculum Format + +Curricula are written in GitHub Flavored Markdown. Project metadata is defined in a separate `projects.json` file: + +**`config/projects.json`**: +```json +[ + { + "id": "e5f6a1b2-c3d4-4e5f-1a2b-3c4d5e6f7a8b", + "title": "Course Title", + "dashed_name": "course-title", + "order": 0, + "is_integrated": false, + "is_public": true, + "run_tests_on_watch": true, + "seed_every_lesson": false, + "is_reset_enabled": true + } +] +``` + +**`curriculum/locales/english/course-title.md`**: +````markdown +# Course Title + +Course description and introduction. + +## 0 + +### --description-- + +Lesson description goes here. + +### --tests-- + +```js,runner=node +console.log("Test"); +assert(true); +``` + +### --seed-- + +```js,runner=node +// Starter code +const x = 1; +``` + +## 1 + +### --description-- + +Next lesson... +```` + +## API Endpoints + +- `GET /health` - Health check +- `GET /api/curriculum/:project` - Get curriculum metadata +- `POST /api/tests/:project/:lesson` - Run tests for a lesson +- `POST /api/reset/:project/:lesson` - Reset a lesson to seed state + +## Dependency Versions + +Latest versions as of February 2026: + +### Frontend +- React 19.2.4 +- TypeScript 5.9.3 +- Vite 7.3.1 +- Vite React Plugin 5.1.4 +- Marked 17.0.3 +- TanStack Query 5.90.21 + +### Backend +- Axum 0.7 +- Tokio 1.35 +- Serde 1.0 +- Comrak (Markdown parser) + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. + +## License + +BSD-3-Clause License - See [LICENSE](./LICENSE) for details + +## Migration Notes + +This version represents a complete rewrite from the Node.js architecture: + +- **Old**: Express + Webpack + Node.js test runner +- **New**: Axum + Vite + Rust server with embedded client + +Key improvements: +- 10x smaller binary size +- Faster test execution +- Single distribution binary +- Better error handling +- Type-safe configuration +- Modular architecture + +For migration guide from v3.x, see [MIGRATION.md](./MIGRATION.md). diff --git a/bun.lock b/bun.lock new file mode 100644 index 00000000..7b1bfc52 --- /dev/null +++ b/bun.lock @@ -0,0 +1,253 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@freecodecamp/freecodecamp-os", + "dependencies": { + "@tanstack/react-query": "^5.90.21", + "marked": "^17.0.3", + "marked-highlight": "^2.2.3", + "prismjs": "^1.30.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "vite-plugin-prismjs": "0.0.11", + }, + "devDependencies": { + "@types/bun": "1.3.7", + "@types/prismjs": "1.26.5", + "@types/react": "19.2.10", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.2", + "babel-plugin-react-compiler": "^1.0.0", + "typescript": "^5.9.3", + "vite": "npm:rolldown-vite@7.3.1", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@oxc-project/runtime": ["@oxc-project/runtime@0.101.0", "", {}, "sha512-t3qpfVZIqSiLQ5Kqt/MC4Ge/WCOGrrcagAdzTcDaggupjiGxUx4nJF2v6wUCXWSzWHn5Ns7XLv13fCJEwCOERQ=="], + + "@oxc-project/types": ["@oxc-project/types@0.101.0", "", {}, "sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.53", "", { "os": "android", "cpu": "arm64" }, "sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.53", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yIsKqMz0CtRnVa6x3Pa+mzTihr4Ty+Z6HfPbZ7RVbk1Uxnco4+CUn7Qbm/5SBol1JD/7nvY8rphAgyAi7Lj6Vg=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.53", "", { "os": "darwin", "cpu": "x64" }, "sha512-GTXe+mxsCGUnJOFMhfGWmefP7Q9TpYUseHvhAhr21nCTgdS8jPsvirb0tJwM3lN0/u/cg7bpFNa16fQrjKrCjQ=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.53", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Tmp7bBvKqyDkMcL4e089pH3RsjD3SUungjmqWtyhNOxoQMh0fSmINTyYV8KXtE+JkxYMPWvnEt+/mfpVCkk8w=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53", "", { "os": "linux", "cpu": "arm" }, "sha512-a1y5fiB0iovuzdbjUxa7+Zcvgv+mTmlGGC4XydVIsyl48eoxgaYkA3l9079hyTyhECsPq+mbr0gVQsFU11OJAQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53", "", { "os": "linux", "cpu": "arm64" }, "sha512-bpIGX+ov9PhJYV+wHNXl9rzq4F0QvILiURn0y0oepbQx+7stmQsKA0DhPGwmhfvF856wq+gbM8L92SAa/CBcLg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.53", "", { "os": "linux", "cpu": "arm64" }, "sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.53", "", { "os": "linux", "cpu": "x64" }, "sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.53", "", { "os": "linux", "cpu": "x64" }, "sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-beta.53", "", { "os": "none", "cpu": "arm64" }, "sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.53", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.0" }, "cpu": "none" }, "sha512-BUjAEgpABEJXilGq/BPh7jeU3WAJ5o15c1ZEgHaDWSz3LB881LQZnbNJHmUiM4d1JQWMYYyR1Y490IBHi2FPJg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53", "", { "os": "win32", "cpu": "arm64" }, "sha512-s27uU7tpCWSjHBnxyVXHt3rMrQdJq5MHNv3BzsewCIroIw3DJFjMH1dzCPPMUFxnh1r52Nf9IJ/eWp6LDoyGcw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.53", "", { "os": "win32", "cpu": "x64" }, "sha512-cjWL/USPJ1g0en2htb4ssMjIycc36RvdQAx1WlXnS6DpULswiUTVXPDesTifSKYSyvx24E0YqQkEm0K/M2Z/AA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="], + + "@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], + + "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + + "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], + + "babel-plugin-prismjs": ["babel-plugin-prismjs@2.1.0", "", { "peerDependencies": { "prismjs": "^1.18.0" } }, "sha512-ehzSKYfeAz4U78zi/sfwsjDPlq0LvDKxNefcZTJ/iKBu+plsHsLqZhUeGf1+82LAcA35UZGbU6ksEx2Utphc/g=="], + + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "marked": ["marked@17.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A=="], + + "marked-highlight": ["marked-highlight@2.2.3", "", { "peerDependencies": { "marked": ">=4 <18" } }, "sha512-FCfZRxW/msZAiasCML4isYpxyQWKEEx44vOgdn5Kloae+Qc3q4XR7WjpKKf8oMLk7JP9ZCRd2vhtclJFdwxlWQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "rolldown": ["rolldown@1.0.0-beta.53", "", { "dependencies": { "@oxc-project/types": "=0.101.0", "@rolldown/pluginutils": "1.0.0-beta.53" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.53", "@rolldown/binding-darwin-arm64": "1.0.0-beta.53", "@rolldown/binding-darwin-x64": "1.0.0-beta.53", "@rolldown/binding-freebsd-x64": "1.0.0-beta.53", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.53", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.53", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.53", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.53", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.53", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.53", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.53", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.53", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.53" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Qd9c2p0XKZdgT5AYd+KgAMggJ8ZmCs3JnS9PTMWkyUfteKlfmKtxJbWTHkVakxwXs1Ub7jrRYVeFeF7N0sQxyw=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["rolldown-vite@7.3.1", "", { "dependencies": { "@oxc-project/runtime": "0.101.0", "fdir": "^6.5.0", "lightningcss": "^1.30.2", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.53", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-LYzdNAjRHhF2yA4JUQm/QyARyi216N2rpJ0lJZb8E9FU2y5v6Vk+xq/U4XBOxMefpWixT5H3TslmAHm1rqIq2w=="], + + "vite-plugin-prismjs": ["vite-plugin-prismjs@0.0.11", "", { "dependencies": { "@babel/core": "^7.15.5", "babel-plugin-prismjs": "^2.1.0" } }, "sha512-20NBQxg/zH+3FTrlU6BQTob720xkuXNYtrx7psAQ4E6pMcRDeLEK77QU9kXURU587+f2To7ASH1JVTGbXVV/vQ=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + } +} diff --git a/cli/.vscode b/cli/.vscode new file mode 120000 index 00000000..3650a971 --- /dev/null +++ b/cli/.vscode @@ -0,0 +1 @@ +../example/.vscode \ No newline at end of file diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 787e0e0d..7738416c 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,16 +1,23 @@ [package] -name = "create-freecodecamp-os-app" -version = "3.0.2" -edition = "2021" +name = "freecodecamp-os-cli" +version.workspace = true +edition.workspace = true description = "CLI to create the boilerplate for a new freeCodeCamp-OS app" -license = "BSD-3-Clause" +license.workspace = true documentation = "https://opensource.freecodecamp.org/freeCodeCampOS/cli.html" -homepage = "https://opensource.freecodecamp.org/freeCodeCampOS/" -repository = "https://github.com/freeCodeCamp/freeCodeCampOS" +homepage.workspace = true +repository.workspace = true [dependencies] -clap = { version = "4.5.4", features = ["derive"] } -indicatif = "0.17.7" -inquire = "0.7.0" -serde = { version = "1.0.200", features = ["derive"] } -serde_json = "1.0.116" +config = { path = "../config" } +parser = { path = "../parser" } +runner = { path = "../runner" } +clap = { version = "4.6.0", features = ["derive"] } +indicatif = "0.18.4" +inquire = "0.9.4" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +tokio = { version = "1.50.0", features = ["full"] } +tracing = "0.1" +anyhow = "1.0" +uuid = { version = "1.22.0", features = ["v4", "serde"] } diff --git a/cli/bash b/cli/bash index 75809262..6ad377b5 120000 --- a/cli/bash +++ b/cli/bash @@ -1 +1 @@ -../self/bash/ \ No newline at end of file +../example/bash/ \ No newline at end of file diff --git a/cli/src/clapper.rs b/cli/src/clapper.rs index d72e8dbc..b89231e0 100644 --- a/cli/src/clapper.rs +++ b/cli/src/clapper.rs @@ -5,11 +5,13 @@ use std::{fs::canonicalize, path::Path}; use crate::conf::Conf; use crate::features::Features; use crate::fs::Course; -use crate::{conf::Project, environment::Environment}; +use crate::environment::Environment; use indicatif::ProgressBar; use inquire::{ - error::InquireResult, min_length, validator::Validation, Confirm, CustomType, MultiSelect, Text, + error::InquireResult, min_length, validator::Validation, Confirm, CustomType, MultiSelect, Select, Text, }; +use config::ProjectMeta as Project; +use uuid::Uuid; #[derive(Debug, Parser)] /// A CLI tool to help developers create courses for freeCodeCamp @@ -20,10 +22,16 @@ pub struct Cli { #[derive(Debug, Subcommand)] pub enum SubCommand { + /// Create a new course in the current directory + Create, /// Add a project to an existing course /// /// Run this command in the root directory of the course. AddProject, + /// Rename a project in an existing course + RenameProject, + /// Validate the course configuration files + Validate, } /// Appends and creates the necessary metadata for a new project within an existing course @@ -63,16 +71,17 @@ pub fn add_project() -> InquireResult<()> { .with_default(true) .prompt()?; let mut seed_every_lesson = false; - let mut blocking_tests = false; - let mut break_on_failure = false; + let mut blocking_tests = None; + let mut break_on_failure = None; if is_integrated { - blocking_tests = Confirm::new("Blocking tests?") + let is_blocking = Confirm::new("Blocking tests?") .with_default(true) .prompt()?; - if blocking_tests { - break_on_failure = Confirm::new("Break on failure?") + blocking_tests = Some(is_blocking); + if is_blocking { + break_on_failure = Some(Confirm::new("Break on failure?") .with_default(true) - .prompt()?; + .prompt()?); } } else { seed_every_lesson = Confirm::new("Seed every lesson?") @@ -88,14 +97,14 @@ pub fn add_project() -> InquireResult<()> { let mut projects = get_projects(); let latest_project = projects.last(); - let id = match latest_project { - Some(project) => project.id + 1, - None => 1, + let order = match latest_project { + Some(project) => project.order + 1, + None => 0, }; let project = Project { - id, + id: Uuid::new_v4(), + order, dashed_name, - current_lesson: 0, is_integrated, is_public, run_tests_on_watch, @@ -103,7 +112,8 @@ pub fn add_project() -> InquireResult<()> { is_reset_enabled, blocking_tests, break_on_failure, - number_of_lessons: 1, + number_of_lessons: Some(1), + tags: vec![], }; projects.push(project); create_project_metadata(&freecodecamp_conf, &projects); @@ -111,6 +121,208 @@ pub fn add_project() -> InquireResult<()> { Ok(()) } +/// Renames a project within an existing course +pub fn rename_project() -> InquireResult<()> { + let mut projects = get_projects(); + let freecodecamp_conf = get_config(); + + let project_dashed_names: Vec = projects.iter().map(|p| p.dashed_name.clone()).collect(); + let selected_dashed_name = Select::new("Which project to rename?", project_dashed_names).prompt()?; + + let project_index = projects + .iter() + .position(|p| p.dashed_name == selected_dashed_name) + .unwrap(); + let old_dashed_name = projects[project_index].dashed_name.clone(); + + // Read title from the curriculum file H1 + let curriculum_dir_path = PathBuf::from(freecodecamp_conf.curriculum.locales.english.clone()); + let old_curriculum_path_for_title = curriculum_dir_path.join(format!("{old_dashed_name}.md")); + let old_title = std::fs::read_to_string(&old_curriculum_path_for_title) + .ok() + .and_then(|content| { + content + .lines() + .next() + .and_then(|l| l.strip_prefix("# ")) + .map(|t| t.to_string()) + }) + .unwrap_or_default(); + + let validator = |input: &str| { + if input.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + Ok(Validation::Valid) + } else { + Ok(Validation::Invalid( + "Your dashed name should be a valid string for a file and directory name".into(), + )) + } + }; + let new_dashed_name = Text::new("New dashed name of project?") + .with_default(&old_dashed_name) + .with_validator(validator) + .prompt()?; + let new_title = Text::new("New title of project?") + .with_default(&old_title) + .with_validator(min_length!(1, "Minimum of 1 character")) + .prompt()?; + + // Rename project directory + if old_dashed_name != new_dashed_name { + if let Err(e) = std::fs::rename(&old_dashed_name, &new_dashed_name) { + eprintln!( + "Warning: Failed to rename directory '{}' to '{}': {e}", + old_dashed_name, new_dashed_name + ); + } + } + + // Rename curriculum file and update H1 + let old_curriculum_path = curriculum_dir_path.join(format!("{old_dashed_name}.md")); + let new_curriculum_path = curriculum_dir_path.join(format!("{new_dashed_name}.md")); + + if old_curriculum_path.exists() { + // Read file + let mut content = + std::fs::read_to_string(&old_curriculum_path).expect("Failed to read curriculum file"); + // Update H1 + if content.starts_with("# ") { + if let Some(first_line_end) = content.find('\n') { + content.replace_range(2..first_line_end, &new_title); + } else { + content = format!("# {new_title}"); + } + } + + // Write to new path + std::fs::write(&new_curriculum_path, content).expect("Failed to write curriculum file"); + + // Remove old file if it was renamed + if old_dashed_name != new_dashed_name { + if let Err(e) = std::fs::remove_file(&old_curriculum_path) { + eprintln!("Warning: Failed to remove old curriculum file: {e}"); + } + } + } else { + eprintln!( + "Warning: Curriculum file '{}' not found", + old_curriculum_path.display() + ); + } + + // Update metadata + projects[project_index].dashed_name = new_dashed_name; + create_project_metadata(&freecodecamp_conf, &projects); + + println!("Project renamed successfully"); + Ok(()) +} + +/// Validates the course configuration files +pub fn validate() -> InquireResult<()> { + let path = Path::new("freecodecamp.conf.json"); + println!("Validating freecodecamp.conf.json..."); + let file = match std::fs::read(path) { + Ok(file) => file, + Err(e) => { + eprintln!("Error opening config file: {e}"); + return Ok(()); + } + }; + + let freecodecamp_conf: Conf = match serde_json::from_slice(&file) { + Ok(conf) => { + println!(" ✅ freecodecamp.conf.json is valid"); + conf + } + Err(e) => { + eprintln!(" ❌ Error parsing freecodecamp.conf.json: {e}"); + return Ok(()); + } + }; + + println!("Validating projects.json..."); + let projects_path = &freecodecamp_conf.config.projects; + let projects_file = match std::fs::read_to_string(projects_path) { + Ok(file) => file, + Err(e) => { + eprintln!(" ❌ Error reading projects.json file at '{}': {e}", projects_path); + return Ok(()); + } + }; + + match serde_json::from_str::>(&projects_file) { + Ok(_) => { + println!(" ✅ projects.json is valid"); + } + Err(e) => { + eprintln!(" ❌ Error parsing projects.json: {e}"); + } + }; + + println!("Validating state.json..."); + let state_path = &freecodecamp_conf.config.state; + let state_file = match std::fs::read_to_string(state_path) { + Ok(file) => file, + Err(e) => { + eprintln!(" ❌ Error reading state.json file at '{}': {e}", state_path); + return Ok(()); + } + }; + + match serde_json::from_str::(&state_file) { + Ok(_) => { + println!(" ✅ state.json is valid"); + } + Err(e) => { + eprintln!(" ❌ Error parsing state.json: {e}"); + } + }; + + println!("\nValidation complete."); + Ok(()) +} + +/// Creates all the minimum boilerplate in the current directory +pub fn create_boilerplate() -> InquireResult<()> { + let current_dir = std::env::current_dir().expect("unable to get current directory"); + let directory_name = current_dir + .file_name() + .expect("unable to get directory name") + .to_str() + .expect("unable to convert directory name to string") + .to_string(); + + println!("Creating course in current directory: {directory_name}"); + + let is_git_repository = Confirm::new("Initialise as a git repository?") + .with_default(true) + .prompt()?; + + let is_translated = Confirm::new("Is this course going to be translated?") + .with_default(true) + .prompt()?; + + let course = Course::new( + current_dir, + directory_name, + vec![Environment::VSCode], + vec![], + is_git_repository, + is_translated, + 1, + ); + + let pb = ProgressBar::new(14); + println!("Creating boilerplate..."); + + course.create_course(&pb); + + pb.finish_with_message("done"); + println!("Start by `npm i`."); + Ok(()) +} + pub fn create_course() -> InquireResult<()> { let directory_name = Text::new("Name of course?") .with_help_message("This will be used as the directory name for the repository.") @@ -182,6 +394,7 @@ pub fn create_course() -> InquireResult<()> { // .prompt()?; let course = Course::new( + canonicalize(&directory_name).expect("unable to canonicalize path"), directory_name.clone(), environment, features, diff --git a/cli/src/conf.rs b/cli/src/conf.rs index 89448cae..5acbedc5 100644 --- a/cli/src/conf.rs +++ b/cli/src/conf.rs @@ -110,65 +110,3 @@ impl<'a> Deserialize<'a> for Version { Version::try_from(s).map_err(serde::de::Error::custom) } } - -#[derive(Serialize, Deserialize, Debug)] -pub struct Project { - pub id: u16, - #[serde(rename = "dashedName")] - pub dashed_name: String, - #[serde(rename = "isIntegrated", default = "default_false")] - pub is_integrated: bool, - #[serde(rename = "isPublic", default = "default_true")] - pub is_public: bool, - #[serde(rename = "currentLesson", default = "default_0")] - pub current_lesson: u16, - #[serde(rename = "runTestsOnWatch", default = "default_false")] - pub run_tests_on_watch: bool, - #[serde(rename = "seedEveryLesson", default = "default_false")] - pub seed_every_lesson: bool, - #[serde(rename = "isResetEnabled", default = "default_false")] - pub is_reset_enabled: bool, - #[serde(rename = "numberofLessons", default = "default_1")] - pub number_of_lessons: u16, - #[serde(rename = "blockingTests", default = "default_false")] - pub blocking_tests: bool, - #[serde(rename = "breakOnFailure", default = "default_false")] - pub break_on_failure: bool, -} - -fn default_false() -> bool { - false -} - -fn default_true() -> bool { - true -} - -fn default_1() -> u16 { - 1 -} - -fn default_0() -> u16 { - 0 -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct State { - #[serde(rename = "currentProject")] - /// The current project the user is working on as a `String` or `Value::Null` - pub current_project: Value, - pub locale: String, - #[serde(rename = "lastSeed")] - pub last_seed: Option, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct LastSeed { - #[serde(rename = "projectDashedName")] - pub project_dashed_name: Value, - #[serde(rename = "lessonNumber")] - /// The lesson number last seeded - /// - /// Can be -1, because lessons start at 0, and -1 is used to indicate that no lesson has been seeded - pub lesson_number: i16, -} diff --git a/cli/src/fixtures.rs b/cli/src/fixtures.rs index f4f8d731..a9ccd675 100644 --- a/cli/src/fixtures.rs +++ b/cli/src/fixtures.rs @@ -1,2 +1,3 @@ pub static BASHRC: &str = include_str!("../bash/.bashrc"); pub static SOURCERER: &str = include_str!("../bash/sourcerer.sh"); +pub static VSCODE_SETTINGS: &str = include_str!("../.vscode/settings.json"); diff --git a/cli/src/fs.rs b/cli/src/fs.rs index d8f6ef9a..51832ca3 100644 --- a/cli/src/fs.rs +++ b/cli/src/fs.rs @@ -1,20 +1,19 @@ use std::path::PathBuf; use indicatif::ProgressBar; -use serde_json::{json, Value}; +use serde_json::json; + +use config::{CourseState as State, ProjectMeta as Project}; use crate::{ - conf::{ - Client, Conf, Config, Curriculum, HotReload, Landing, Locales, Project, State, Tooling, - }, + conf::{Client, Conf, Config, Curriculum, HotReload, Landing, Locales, Tooling}, environment::Environment, features::Features, - fixtures::{BASHRC, SOURCERER}, + fixtures::{BASHRC, SOURCERER, VSCODE_SETTINGS}, }; pub struct Course { canonicalized_path: PathBuf, - #[allow(unused)] directory_name: String, environment: Vec, features: Vec, @@ -73,7 +72,8 @@ impl Course { } pub fn new( - directory_name: String, + path: PathBuf, + name: String, environment: Vec, features: Vec, is_git_repository: bool, @@ -81,8 +81,8 @@ impl Course { num_projects: u8, ) -> Self { Self { - canonicalized_path: std::fs::canonicalize(&directory_name).expect("Failed to get path"), - directory_name, + canonicalized_path: path, + directory_name: name, environment, features, is_git_repository, @@ -221,7 +221,7 @@ impl Course { eprintln!("Failed to create curriculum directory: {e}"); } else { let boilerplate = r"# Project {i} - + Project description. ## 0 @@ -353,17 +353,18 @@ import { join } from 'path'; fn touch_injectables(&self) { if self.features.contains(&Features::ScriptInjection) { let injectable = r"function checkForToken() { - const serverTokenCode = ` - try { - const {readFile} = await import('fs/promises'); - const tokenFile = await readFile(join(ROOT, 'config/token.txt')); - const token = tokenFile.toString(); - console.log(token); - __result = token; - } catch (e) { - console.error(e); - __result = null; - }`; + const serverTokenCode = `#!/usr/bin/env node +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +const ROOT = process.cwd(); + +try { + const tokenFile = await readFile(join(ROOT, 'config/token.txt')); + const token = tokenFile.toString(); + console.log(token); +} catch (e) { + process.exit(1); +}`; socket.send( JSON.stringify({ event: '__run-client-code', @@ -386,15 +387,17 @@ async function askForToken() { button.style.color = 'black'; button.onclick = async () => { const token = input.value; - const serverTokenCode = ` - try { - const {writeFile} = await import('fs/promises'); - await writeFile(join(ROOT, 'config/token.txt'), '${token}'); - __result = true; - } catch (e) { - console.error(e); - __result = false; - }`; + const serverTokenCode = `#!/usr/bin/env node +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +const ROOT = process.cwd(); + +try { + await writeFile(join(ROOT, 'config/token.txt'), '${token}'); + process.exit(0); +} catch (e) { + process.exit(1); +}`; socket.send( JSON.stringify({ event: '__run-client-code', @@ -414,7 +417,7 @@ async function askForToken() { const socket = new WebSocket( `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${ window.location.host - }` + }/ws` ); window.onload = function () { @@ -425,15 +428,16 @@ window.onload = function () { parsedData.data.event === '__run-client-code' ) { if (parsedData.data.error) { - console.log(parsedData.data.error); + console.error(parsedData.data.error); return; } - const { __result } = parsedData.data; - if (!__result) { + + const { stdout, exit_code } = parsedData.data; + if (exit_code !== 0 || !stdout.trim()) { askForToken(); return; } - window.__token = __result; + window.__token = stdout.trim(); } }; let interval; @@ -489,17 +493,18 @@ pluginEvents.onLessonPassed = async project => {}; let mut projects = Vec::new(); for i in 0..self.num_projects { let project = Project { - id: u16::from(i), + id: uuid::Uuid::new_v4(), + order: i as u32, dashed_name: format!("project-{i}"), is_integrated: false, is_public: true, - current_lesson: 0, run_tests_on_watch: true, seed_every_lesson: false, is_reset_enabled: false, - number_of_lessons: 1, - blocking_tests: false, - break_on_failure: false, + number_of_lessons: Some(1), + blocking_tests: None, + break_on_failure: None, + tags: vec![], }; projects.push(project); } @@ -514,9 +519,10 @@ pluginEvents.onLessonPassed = async project => {}; fn touch_state(&self) { let state = State { - current_project: Value::Null, + current_project: None, locale: "english".to_string(), last_seed: None, + current_lessons: std::collections::HashMap::new(), }; if let Err(e) = std::fs::create_dir_all(self.canonicalized_path.join("config")) { eprintln!("Failed to create config directory: {e}"); @@ -529,48 +535,18 @@ pluginEvents.onLessonPassed = async project => {}; } fn touch_vscode(&self) { - let settings = json!({ - "files.exclude": { - ".devcontainer": false, - ".gitignore": false, - ".gitpod.yml": false, - ".logs": false, - ".vscode": false, - "node_modules": false, - "package.json": false, - "package-lock.json": false - }, - "terminal.integrated.defaultProfile.linux": "bash", - "terminal.integrated.profiles.linux": { - "bash": { - "path": "bash", - "icon": "terminal-bash", - "args": [ - "--init-file", - "./bash/sourcerer.sh" - ] - } - }, - "freecodecamp-courses.autoStart": true, - "freecodecamp-courses.prepare": "sed -i \"s#WD=.*#WD=$(pwd)#g\" ./bash/.bashrc", - "freecodecamp-courses.scripts.develop-course": "NODE_ENV=development npm run start", - "freecodecamp-courses.scripts.run-course": "NODE_ENV=production npm run start", - "freecodecamp-courses.workspace.previews": [ - { - "open": true, - "url": "http://localhost:8080", - "showLoader": true, - "timeout": 4000 - } - ] - }); - if let Err(e) = std::fs::create_dir_all(self.canonicalized_path.join(".vscode")) { - eprintln!("Failed to create .vscode directory: {e}"); - } else if let Err(e) = std::fs::write( - self.canonicalized_path.join(".vscode/settings.json"), - serde_json::to_string_pretty(&settings).expect("Failed to serialise settings"), - ) { - eprintln!("Failed to create .vscode/settings.json file: {e}"); + if std::fs::DirBuilder::new() + .create(self.canonicalized_path.join(".vscode")) + .is_ok() + { + if let Err(e) = std::fs::write( + self.canonicalized_path.join(".vscode/settings.json"), + VSCODE_SETTINGS, + ) { + eprintln!("Failed to create .vscode/settings.json file: {e}"); + } + } else { + eprintln!("Failed to create .vscode directory"); } } } diff --git a/cli/src/main.rs b/cli/src/main.rs index 428e10b2..5e0a34a0 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -2,7 +2,7 @@ #![allow(clippy::struct_excessive_bools)] use clap::Parser; -use clapper::{add_project, create_course, Cli, SubCommand}; +use clapper::{add_project, create_boilerplate, create_course, rename_project, validate, Cli, SubCommand}; use inquire::error::InquireResult; mod clapper; @@ -16,9 +16,18 @@ fn main() -> InquireResult<()> { let args = Cli::parse(); match args.sub_commands { + Some(SubCommand::Create) => { + create_boilerplate()?; + } Some(SubCommand::AddProject) => { add_project()?; } + Some(SubCommand::RenameProject) => { + rename_project()?; + } + Some(SubCommand::Validate) => { + validate()?; + } None => { create_course()?; } diff --git a/.freeCodeCamp/client/assets/Lato-Regular.woff b/client/assets/Lato-Regular.woff similarity index 100% rename from .freeCodeCamp/client/assets/Lato-Regular.woff rename to client/assets/Lato-Regular.woff diff --git a/client/assets/fcc_primary_large.tsx b/client/assets/fcc_primary_large.tsx new file mode 100644 index 00000000..a047c286 --- /dev/null +++ b/client/assets/fcc_primary_large.tsx @@ -0,0 +1,6 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +function FreeCodeCampLogo(props) { + return (_jsxs("svg", { height: 24, version: '1.1', viewBox: '0 0 210 24', width: 210, xmlns: 'http://www.w3.org/2000/svg', xmlnsXlink: 'http://www.w3.org/1999/xlink', ...props, children: [_jsxs("defs", { children: [_jsx("path", { d: 'm35.42 5.56 0.43 0.05 0.42 0.08 0.39 0.09 0.37 0.12 0.36 0.14 0.32 0.16 0.31 0.18 0.28 0.21 0.27 0.22 0.24 0.24 0.22 0.27 0.2 0.28 0.18 0.31 0.16 0.33 0.13 0.35 0.12 0.37 0.09 0.39 0.08 0.41 0.05 0.44 0.03 0.45 0.01 0.47v0.12 0.11l-0.01 0.1-0.04 0.2-0.04 0.18-0.03 0.08-0.07 0.15-0.04 0.06-0.05 0.06-0.04 0.06-0.06 0.05-0.05 0.05-0.06 0.03-0.06 0.04-0.07 0.03-0.08 0.02-0.07 0.02-0.16 0.02h-8.9v-0.07h-0.02v1.84l0.01 0.24 0.03 0.24 0.03 0.23 0.06 0.22 0.07 0.2 0.09 0.2 0.1 0.17 0.12 0.18 0.13 0.15 0.15 0.16 0.17 0.13 0.18 0.13 0.2 0.11 0.21 0.11 0.23 0.09 0.24 0.09 0.27 0.06 0.27 0.07 0.3 0.05 0.31 0.03 0.32 0.03 0.34 0.02 0.36 0.01h0.13l0.13-0.01h0.13l0.12-0.01h0.13l0.24-0.02 0.23-0.02 0.11-0.01 0.11-0.02 0.21-0.03 0.1-0.01 0.1-0.02 0.29-0.06 0.09-0.03 0.09-0.02 0.08-0.03 0.09-0.03 0.08-0.03 0.05-0.01 0.15-0.06 0.06-0.03 0.06-0.02 0.12-0.06 0.21-0.11 0.08-0.04 0.07-0.05 0.17-0.09 0.08-0.05 0.09-0.05 0.09-0.06 0.19-0.13 0.1-0.06 0.1-0.07 0.11-0.07 0.12-0.12 0.13-0.1 0.06-0.05 0.05-0.04 0.06-0.04 0.05-0.05 0.09-0.07 0.1-0.06 0.04-0.03 0.04-0.02 0.04-0.03 0.07-0.03 0.03-0.01 0.04-0.01 0.02-0.01 0.05-0.01h0.09l0.1 0.01 0.15 0.03 0.04 0.02 0.07 0.04 0.04 0.03 0.09 0.09 0.03 0.04 0.04 0.08 0.01 0.05 0.02 0.05 0.01 0.06 0.02 0.1v0.07 0.07 0.06l-0.01 0.07-0.01 0.06-0.01 0.07-0.06 0.2-0.03 0.06-0.04 0.07-0.03 0.07-0.04 0.07-0.05 0.07-0.05 0.06-0.1 0.14-0.13 0.13-0.13 0.14-0.16 0.14-0.09 0.06-0.08 0.08-0.15 0.1-0.15 0.11-0.32 0.2-0.17 0.09-0.18 0.09-0.18 0.08-0.19 0.07-0.19 0.08-0.21 0.07-0.42 0.12-0.22 0.05-0.23 0.05-0.47 0.09-0.5 0.06-0.52 0.04-0.27 0.01-0.28 0.01h-0.28-0.48l-0.47-0.03-0.45-0.04-0.42-0.07-0.41-0.07-0.38-0.09-0.36-0.11-0.34-0.13-0.31-0.15-0.3-0.16-0.27-0.18-0.26-0.2-0.22-0.21-0.21-0.23-0.19-0.25-0.16-0.26-0.14-0.29-0.13-0.29-0.1-0.32-0.07-0.34-0.06-0.35-0.03-0.37-0.01-0.39v-4.71l0.01-0.14 0.01-0.12 0.01-0.13 0.04-0.26 0.04-0.13 0.03-0.12 0.08-0.24 0.1-0.24 0.12-0.23 0.06-0.11 0.07-0.12 0.08-0.1 0.08-0.11 0.08-0.1 0.09-0.11 0.19-0.2 0.23-0.23 0.3-0.24 0.15-0.11 0.17-0.11 0.17-0.1 0.17-0.09 0.18-0.09 0.38-0.16 0.2-0.07 0.42-0.12 0.22-0.05 0.23-0.05 0.22-0.04 0.24-0.03 0.24-0.04 0.24-0.02 0.25-0.02 0.52-0.02h0.27l0.47 0.01 0.47 0.03zm-2.04 1.6-0.41 0.07-0.39 0.1-0.38 0.13-0.37 0.15-0.35 0.18-0.32 0.25-0.27 0.26-0.23 0.28-0.19 0.3-0.13 0.31-0.08 0.32-0.03 0.35v0.96h8.19l-0.09-0.98-0.25-0.83-0.43-0.69-0.6-0.53-0.76-0.38-0.95-0.23-1.11-0.07-0.43 0.01-0.42 0.04z', id: 'k' }), _jsx("path", { d: 'm107.21 5.56 0.43 0.05 0.42 0.08 0.39 0.09 0.37 0.12 0.35 0.14 0.33 0.16 0.31 0.18 0.29 0.21 0.26 0.22 0.24 0.24 0.22 0.27 0.21 0.28 0.17 0.31 0.16 0.33 0.14 0.35 0.11 0.37 0.1 0.39 0.07 0.41 0.05 0.44 0.03 0.45 0.01 0.47v0.12l-0.01 0.11-0.02 0.2-0.02 0.1-0.02 0.09-0.03 0.09-0.02 0.08-0.03 0.07-0.04 0.08-0.04 0.06-0.1 0.12-0.1 0.1-0.13 0.07-0.13 0.05-0.08 0.02-0.16 0.02-8.92 0.01v1.76l0.01 0.24 0.02 0.24 0.04 0.23 0.06 0.22 0.07 0.2 0.08 0.2 0.11 0.17 0.11 0.18 0.14 0.15 0.15 0.16 0.17 0.13 0.18 0.13 0.19 0.11 0.22 0.11 0.23 0.09 0.24 0.09 0.26 0.06 0.28 0.07 0.3 0.05 0.31 0.03 0.32 0.03 0.34 0.02 0.36 0.01h0.13l0.13-0.01h0.13l0.25-0.01 0.24-0.02 0.22-0.02 0.12-0.01 0.11-0.02 0.31-0.04 0.2-0.04 0.19-0.04 0.09-0.03 0.09-0.02 0.09-0.03 0.08-0.03 0.13-0.04 0.04-0.02 0.06-0.02 0.05-0.02 0.06-0.03 0.05-0.02 0.07-0.03 0.2-0.1 0.14-0.08 0.08-0.05 0.08-0.04 0.08-0.05 0.18-0.1 0.18-0.12 0.2-0.13 0.1-0.07 0.11-0.07 0.06-0.06 0.07-0.06 0.05-0.05 0.07-0.05 0.05-0.05 0.06-0.04 0.11-0.09 0.05-0.03 0.05-0.04 0.04-0.03 0.05-0.03 0.04-0.03 0.04-0.02 0.04-0.03 0.07-0.03 0.12-0.04h0.02 0.09 0.05l0.06 0.01 0.09 0.02 0.05 0.01 0.12 0.06 0.12 0.12 0.03 0.04 0.04 0.08 0.01 0.05 0.02 0.05 0.02 0.11 0.01 0.05v0.07 0.13l-0.01 0.07-0.01 0.06-0.01 0.07-0.04 0.14-0.02 0.06-0.03 0.06-0.04 0.07-0.03 0.07-0.09 0.14-0.05 0.06-0.05 0.07-0.11 0.14-0.21 0.2-0.23 0.2-0.09 0.08-0.3 0.21-0.15 0.1-0.17 0.1-0.17 0.09-0.18 0.09-0.18 0.08-0.19 0.07-0.2 0.08-0.2 0.07-0.42 0.12-0.22 0.05-0.23 0.05-0.23 0.04-0.24 0.05-0.5 0.06-0.52 0.04-0.55 0.02h-0.28-0.49l-0.46-0.03-0.45-0.04-0.43-0.07-0.39-0.07-0.39-0.09-0.36-0.11-0.33-0.13-0.33-0.15-0.29-0.16-0.27-0.18-0.25-0.2-0.23-0.21-0.22-0.23-0.18-0.25-0.17-0.26-0.14-0.29-0.12-0.29-0.1-0.32-0.08-0.34-0.05-0.35-0.04-0.37-0.01-0.39v-4.58l0.01-0.13v-0.14l0.02-0.12 0.01-0.13 0.02-0.13 0.05-0.26 0.12-0.36 0.1-0.24 0.11-0.23 0.07-0.11 0.07-0.12 0.07-0.1 0.09-0.11 0.08-0.1 0.09-0.11 0.19-0.2 0.1-0.1 0.14-0.13 0.14-0.12 0.15-0.12 0.15-0.11 0.17-0.11 0.17-0.1 0.17-0.09 0.19-0.09 0.18-0.08 0.19-0.08 0.2-0.07 0.42-0.12 0.44-0.1 0.23-0.04 0.23-0.03 0.25-0.04 0.24-0.02 0.25-0.02 0.52-0.02h0.27l0.48 0.01 0.46 0.03zm-2.04 1.6-0.41 0.07-0.39 0.1-0.38 0.13-0.37 0.15-0.34 0.18-0.34 0.25-0.29 0.26-0.23 0.28-0.17 0.3-0.13 0.31-0.07 0.32-0.03 0.35v0.96h8.19l-0.09-0.98-0.25-0.83-0.43-0.69-0.6-0.53-0.76-0.38-0.95-0.23-1.11-0.07-0.43 0.01-0.42 0.04z', id: 'j' }), _jsx("path", { d: 'm203.57 0.17c-0.12 0.12-0.24 0.29-0.24 0.45 0 0.29 0.34 0.69 0.97 1.33 2.63 2.53 3.95 5.62 3.94 9.35-0.01 4.13-1.4 7.45-4.1 10.01-0.57 0.51-0.8 0.91-0.8 1.25 0 0.17 0.12 0.34 0.23 0.51 0.11 0.12 0.34 0.23 0.51 0.23 0.62 0 1.5-0.73 2.64-2.17 2.22-2.72 3.22-5.73 3.28-9.82 0.05-4.1-1.23-6.88-3.75-9.75-0.9-1.03-1.66-1.56-2.17-1.56-0.17 0-0.35 0.06-0.51 0.17z', id: 'b' }), _jsx("path", { d: 'm124.75 1.76c1.14 0.86 1.73 2.07 1.73 3.55 0 0.68-0.29 1.02-0.86 1.02-0.39 0-0.68-0.34-0.85-1.02-0.11-0.57-0.34-1.08-0.62-1.62-0.52-0.9-1.61-1.32-3.32-1.32-1.49 0-2.52 0.34-3.14 1.08-0.57 0.68-0.91 1.72-0.91 3.26v5.95c0 1.55 0.34 2.63 0.97 3.31 0.68 0.74 1.72 1.13 3.2 1.13 2.23 0 3.54-0.79 3.82-2.34 0.12-0.57 0.17-0.86 0.17-0.91 0.12-0.34 0.35-0.51 0.68-0.51 0.57 0 0.86 0.34 0.86 1.02 0 1.44-0.57 2.52-1.78 3.38-0.97 0.62-2.18 0.96-3.77 0.96-1.84 0-3.26-0.4-4.3-1.25-1.16-0.8-1.73-2.16-1.73-3.94v-7.16c0-3.77 1.95-5.61 5.95-5.61 1.61 0 2.86 0.34 3.9 1.02z', id: 'n' }), _jsx("path", { d: 'm14.21 6.57c0-0.56 0.34-0.79 1.02-0.79h3.32c0.57 0 0.85 0.51 0.85 1.44 1.02-1.08 2.12-1.73 3.26-1.73 0.96 0 1.72 0.29 2.23 0.86 0.57 0.57 0.8 1.38 0.8 2.29 0 0.63-0.29 0.97-0.8 0.97-0.34 0-0.57-0.23-0.68-0.63-0.23-0.8-0.34-1.19-0.4-1.25-0.22-0.39-0.68-0.62-1.25-0.62-0.62 0-1.25 0.23-1.78 0.68-0.34 0.23-0.8 0.74-1.38 1.49v7.67h3.08c0.68 0 1.03 0.29 1.03 0.8 0 0.57-0.35 0.86-1.03 0.86h-7.33c-0.68 0-1.02-0.29-1.02-0.8 0-0.57 0.34-0.8 1.02-0.8h2.52v-0.07h0.02v-9.57h-2.46c-0.68 0-1.02-0.28-1.02-0.8z', id: 'l' }), _jsx("path", { d: 'm96.68 0.04 0.06 0.02 0.06 0.03 0.05 0.03 0.06 0.04 0.13 0.13 0.03 0.06 0.04 0.06 0.03 0.07 0.02 0.07 0.04 0.16 0.04 0.18 0.01 0.2v0.1 16.84 0.08l-0.01 0.07v0.07l-0.04 0.13-0.01 0.05-0.03 0.06-0.02 0.05-0.06 0.09-0.07 0.08-0.05 0.03-0.04 0.03-0.05 0.03-0.1 0.04-0.06 0.02-0.12 0.02-0.07 0.01h-0.11l-0.13-0.02-0.04-0.01-0.04-0.02-0.04-0.01-0.03-0.02-0.03-0.01-0.03-0.03-0.03-0.02-0.06-0.05-0.02-0.03-0.07-0.12-0.01-0.04-0.02-0.05-0.01-0.04-0.02-0.05v-0.05-0.08-0.04-0.04l-0.01-0.04v-0.04l-0.01-0.04v-0.04-0.04l-0.01-0.05v-0.04-0.05l-0.01-0.09v-0.06l-0.01-0.04v-0.06-0.11l-0.01-0.06v-0.13l-0.13 0.09-0.13 0.08-0.13 0.09-0.24 0.14-0.11 0.08-0.12 0.07-0.21 0.12-0.1 0.06-0.09 0.06-0.1 0.05-0.25 0.14-0.14 0.08-0.14 0.06-0.12 0.05-0.05 0.02-0.09 0.04-0.09 0.03-0.09 0.02-0.19 0.06-0.4 0.08-0.21 0.03-0.22 0.03-0.11 0.02-0.23 0.02h-0.12l-0.12 0.01h-0.11l-0.12 0.01h-0.46l-0.21-0.01-0.2-0.01-0.2-0.02-0.19-0.01-0.2-0.03-0.18-0.02-0.37-0.07-0.34-0.08-0.18-0.05-0.32-0.1-0.32-0.12-0.15-0.07-0.15-0.06-0.42-0.24-0.13-0.08-0.14-0.1-0.28-0.22-0.12-0.11-0.12-0.13-0.11-0.12-0.11-0.13-0.1-0.14-0.09-0.14-0.17-0.29-0.07-0.15-0.07-0.16-0.06-0.16-0.05-0.16-0.05-0.18-0.04-0.18-0.03-0.17-0.02-0.19-0.03-0.19-0.01-0.19v-5.21l0.01-0.19 0.03-0.18 0.02-0.18 0.03-0.18 0.08-0.34 0.05-0.16 0.06-0.16 0.06-0.15 0.08-0.15 0.07-0.15 0.18-0.28 0.2-0.26 0.1-0.12 0.24-0.24 0.26-0.22 0.14-0.1 0.39-0.24 0.15-0.07 0.28-0.14 0.31-0.12 0.15-0.05 0.33-0.1 0.33-0.08 0.72-0.12 0.37-0.03 0.38-0.02h0.45l0.25 0.02h0.12 0.13l0.12 0.02 0.12 0.01 0.22 0.02 0.22 0.04 0.11 0.01 0.21 0.04 0.1 0.02 0.09 0.02 0.28 0.08 0.09 0.03 0.09 0.04 0.08 0.03 0.09 0.03 0.08 0.04 0.09 0.03 0.09 0.04 0.1 0.05 0.17 0.09 0.29 0.16 0.09 0.05 0.29 0.19 0.2 0.14 0.09 0.07 0.4 0.32v-5.89l0.01-0.1v-0.2l0.04-0.18 0.01-0.08 0.03-0.08 0.02-0.07 0.03-0.07 0.03-0.06 0.04-0.06 0.04-0.04 0.04-0.05 0.04-0.04 0.05-0.04 0.1-0.06 0.06-0.02 0.13-0.03 0.06-0.01h0.14l0.07 0.01 0.14 0.03zm-5.7 7.19-0.26 0.03-0.26 0.02-0.25 0.05-0.24 0.05-0.23 0.05-0.42 0.16-0.19 0.09-0.18 0.1-0.2 0.15-0.2 0.16-0.17 0.17-0.15 0.17-0.14 0.19-0.11 0.2-0.1 0.2-0.08 0.23-0.05 0.23-0.04 0.23-0.01 0.26v4.52l0.01 0.26 0.03 0.25 0.05 0.23 0.07 0.23 0.09 0.21 0.12 0.2 0.13 0.19 0.16 0.17 0.17 0.16 0.2 0.14 0.22 0.14 0.19 0.09 0.42 0.16 0.22 0.07 0.22 0.06 0.24 0.06 0.25 0.04 0.26 0.04 0.27 0.03 0.56 0.02 0.47-0.01 0.44-0.04 0.43-0.06 0.21-0.04 0.2-0.06 0.38-0.12 0.17-0.07 0.14-0.1 0.16-0.11 0.15-0.11 0.16-0.12 0.17-0.12 0.16-0.13 0.18-0.13 0.17-0.15 0.18-0.15 0.36-0.32v-6.59l-0.21-0.16-0.21-0.14-0.2-0.14-0.2-0.13-0.19-0.12-0.18-0.11-0.17-0.11-0.16-0.09-0.3-0.14-0.13-0.06-0.19-0.07-0.38-0.12-0.2-0.05-0.4-0.08-0.21-0.03-0.21-0.02-0.21-0.01-0.43-0.01h-0.28l-0.27 0.01z', id: 'c' }), _jsx("path", { d: 'm195.66 12.04c-0.99-0.25 3.06-5.03-4.13-10.75 0 0 0.94 3-3.81 9.69-4.76 6.68 2.11 10.66 2.11 10.66s-3.22-1.72 0.53-7.84c0.67-1.11 1.55-2.11 2.64-4.38 0 0 0.96 1.37 0.46 4.32-0.75 4.47 3.27 3.19 3.33 3.25 1.41 1.65-1.16 4.56-1.32 4.65s7.34-4.5 2.01-11.42c-0.36 0.36-0.83 2.08-1.82 1.82z', id: 'e' }), _jsx("path", { d: 'm135.26 5.37 0.19 0.01 0.18 0.02 0.18 0.01 0.18 0.02 0.34 0.04 0.16 0.02 0.16 0.04 0.15 0.02 0.14 0.04 0.15 0.03 0.28 0.08 0.26 0.08 0.36 0.15 0.12 0.06 0.11 0.05 0.1 0.06 0.11 0.06 0.12 0.08 0.1 0.09 0.11 0.09 0.2 0.2 0.18 0.22 0.16 0.24 0.07 0.12 0.14 0.26 0.12 0.29 0.05 0.14 0.04 0.15 0.05 0.16 0.04 0.15 0.03 0.17 0.02 0.16 0.04 0.36 0.01 0.18 0.02 0.18v6.1 0.08 0.08l0.02 0.28 0.01 0.07 0.02 0.12 0.02 0.06 0.01 0.06 0.03 0.11 0.02 0.05 0.06 0.14 0.03 0.04 0.02 0.04 0.03 0.04 0.06 0.07 0.08 0.08 0.02 0.01v0.01h0.03l0.01 0.02 0.05 0.02 0.02 0.01 0.05 0.02 0.03 0.02 0.04 0.01 0.11 0.04 0.12 0.05 0.1 0.04 0.04 0.01 0.05 0.02 0.05 0.03 0.09 0.03 0.07 0.03 0.02 0.02 0.03 0.02 0.05 0.04 0.02 0.02 0.02 0.03 0.02 0.02 0.04 0.06 0.03 0.06 0.01 0.04 0.01 0.03 0.02 0.08v0.04l0.01 0.04 0.01 0.05v0.05l0.01 0.05v0.21l-0.02 0.05v0.04l-0.01 0.05-0.01 0.04-0.02 0.04-0.01 0.04-0.02 0.03-0.03 0.04-0.05 0.06-0.02 0.03-0.04 0.02-0.11 0.06-0.08 0.04-0.1 0.02-0.1 0.01h-0.06-0.11-0.02-0.01l-0.03-0.01h-0.02-0.02l-0.1-0.02-0.03-0.02h-0.03l-0.06-0.02-0.11-0.03-0.12-0.03-0.05-0.01-0.04-0.01-0.05-0.02-0.21-0.07-0.07-0.02-0.07-0.03-0.24-0.08-0.1-0.04-0.05-0.01-0.04-0.02-0.05-0.01-0.04-0.01-0.04-0.02-0.03-0.01-0.07-0.02-0.07-0.03-0.03-0.01-0.01-0.01-0.02-0.01h-0.03-0.04l-0.03-0.02-0.03-0.01-0.04-0.01-0.02-0.02-0.06-0.04-0.03-0.03-0.02-0.03-0.03-0.03-0.02-0.03-0.02-0.04-0.03-0.04-0.02-0.04-0.01-0.04-0.03-0.04-0.02-0.05-0.03-0.09-0.06-0.21-0.02-0.06-0.23 0.16-0.22 0.15-0.1 0.07-0.2 0.12-0.1 0.07-0.34 0.22-0.08 0.04-0.07 0.05-0.21 0.12-0.06 0.04-0.16 0.08-0.05 0.02-0.09 0.04-0.04 0.01-0.06 0.04-0.07 0.02-0.15 0.05-0.4 0.1-0.08 0.01-0.18 0.03-0.09 0.01-0.09 0.02-0.28 0.03-0.2 0.02h-0.1l-0.11 0.01h-0.1-0.49-0.16l-0.16-0.01-0.31-0.02-0.3-0.03-0.15-0.02-0.15-0.03-0.28-0.05-0.27-0.06-0.14-0.04-0.13-0.04-0.12-0.04-0.13-0.05-0.24-0.09-0.12-0.06-0.11-0.05-0.11-0.06-0.11-0.07-0.11-0.06-0.1-0.07-0.24-0.16-0.22-0.19-0.1-0.1-0.09-0.1-0.1-0.1-0.24-0.33-0.14-0.24-0.06-0.12-0.06-0.13-0.05-0.12-0.05-0.13-0.08-0.27-0.06-0.28-0.04-0.3-0.02-0.3v-0.32l0.01-0.15 0.01-0.16 0.02-0.14 0.05-0.3 0.07-0.28 0.05-0.14 0.11-0.26 0.12-0.26 0.15-0.24 0.16-0.22 0.09-0.11 0.1-0.11 0.2-0.2 0.11-0.09 0.23-0.18 0.13-0.08 0.33-0.24 0.24-0.13 0.26-0.13 0.13-0.05 0.26-0.11 0.14-0.05 0.42-0.12 0.15-0.03 0.14-0.04 0.15-0.02 0.15-0.03 0.16-0.02 0.15-0.01 0.16-0.02 0.48-0.03h0.3l0.13 0.01h0.14l0.29 0.02 0.14 0.02 0.15 0.02 0.14 0.02 0.16 0.02 0.14 0.02 0.15 0.04 0.16 0.02 0.15 0.03 0.32 0.08 0.49 0.12 0.16 0.05 0.51 0.15 0.36 0.12 0.17 0.06v-2.01-0.12l-0.01-0.11-0.01-0.1-0.01-0.12-0.02-0.09-0.02-0.11-0.04-0.1-0.03-0.1-0.03-0.09-0.05-0.09-0.04-0.09-0.11-0.16-0.06-0.09-0.06-0.07-0.07-0.08-0.15-0.14-0.08-0.07-0.18-0.12-0.1-0.06-0.2-0.1-0.11-0.06-0.07-0.03-0.16-0.06-0.34-0.11-0.26-0.06-0.1-0.02-0.09-0.01-0.1-0.02-0.09-0.01-0.3-0.03-0.1-0.02h-0.1l-0.11-0.01-0.1-0.01h-0.11-0.1-0.47l-0.25 0.01-0.24 0.01-0.23 0.02-0.42 0.06-0.2 0.03-0.19 0.04-0.17 0.05-0.17 0.06-0.16 0.05-0.15 0.07-0.14 0.07-0.13 0.07-0.12 0.09-0.12 0.08-0.1 0.1-0.09 0.1-0.09 0.11-0.08 0.11-0.06 0.11-0.06 0.13-0.05 0.13-0.04 0.14-0.03 0.14-0.01 0.07-0.02 0.06-0.04 0.22-0.02 0.09-0.01 0.05-0.01 0.04-0.01 0.03v0.04l-0.02 0.06-0.01 0.06-0.01 0.04v0.02l-0.01 0.02v0.01 0.01 0.03l-0.02 0.03-0.03 0.07-0.01 0.02-0.05 0.06-0.01 0.02-0.03 0.02-0.05 0.05-0.02 0.01-0.02 0.02-0.03 0.01-0.03 0.02-0.09 0.03-0.04 0.01-0.06 0.02h-0.04-0.04l-0.03 0.01h-0.15l-0.06-0.01-0.13-0.02-0.06-0.01-0.15-0.06-0.1-0.06-0.04-0.04-0.04-0.03-0.08-0.08-0.03-0.05-0.05-0.09-0.03-0.06-0.01-0.06-0.04-0.12-0.01-0.06v-0.07l-0.01-0.08v-0.17l0.01-0.1v-0.1l0.02-0.1 0.01-0.1 0.04-0.2 0.05-0.2 0.03-0.1 0.03-0.09 0.08-0.2 0.05-0.1 0.09-0.19 0.18-0.28 0.06-0.09 0.07-0.09 0.22-0.27 0.08-0.09 0.1-0.11 0.22-0.2 0.12-0.09 0.26-0.18 0.14-0.08 0.15-0.08 0.16-0.08 0.15-0.06 0.17-0.07 0.18-0.06 0.18-0.05 0.38-0.1 0.2-0.04 0.42-0.08 0.44-0.05 0.46-0.04 0.24-0.01h0.25l0.25-0.01h0.2l0.2 0.01h0.2zm-2.41 7.31-0.68 0.24-0.54 0.35-0.38 0.44-0.23 0.54-0.07 0.64 0.02 0.34 0.06 0.31 0.11 0.3 0.15 0.27 0.2 0.25 0.25 0.2 0.29 0.17 0.25 0.13 0.28 0.12 0.3 0.09 0.32 0.08 0.34 0.05 0.36 0.03 0.38 0.01 0.34-0.01 0.32-0.02 0.32-0.04 0.31-0.07 0.3-0.07 0.3-0.11 0.29-0.13 0.2-0.12 0.22-0.15 0.25-0.16 0.26-0.18 0.28-0.21 0.3-0.22 0.31-0.23v-2.41l-0.54-0.16-0.53-0.14-0.53-0.11-0.52-0.09-0.51-0.07-0.49-0.04-0.48-0.01-0.98 0.05-0.83 0.14z', id: 'm' }), _jsx("path", { d: 'm0.97 5.8h1.84v-1.61c0-2.8 1.44-4.19 4.24-4.19 1.14 0 2.12 0.23 2.86 0.63 0.96 0.57 1.5 1.5 1.5 2.58 0 0.73-0.29 1.02-0.8 1.02-0.34 0-0.68-0.23-0.86-0.63-0.22-0.73-0.45-1.13-0.56-1.32-0.34-0.4-1.03-0.63-2.01-0.63-1.72 0-2.57 0.85-2.57 2.58v1.55h3.31c0.74 0 1.08 0.29 1.08 0.79 0 0.57-0.34 0.8-1.08 0.8h-3.31v10.48c0 0.62-0.29 0.96-0.8 0.96-0.57 0-0.8-0.34-0.8-0.96v-10.46h-2.04c-0.63 0-0.97-0.28-0.97-0.79 0-0.58 0.34-0.8 0.97-0.8z', id: 'a' }), _jsx("path", { d: 'm78.8 5.55 0.61 0.08 0.56 0.12 0.52 0.15 0.48 0.18 0.43 0.22 0.38 0.25 0.34 0.29 0.29 0.32 0.25 0.35 0.21 0.39 0.16 0.42 0.11 0.45 0.07 0.49 0.02 0.51v4.71l-0.02 0.52-0.07 0.48-0.11 0.46-0.15 0.42-0.2 0.38-0.24 0.35-0.29 0.32-0.33 0.29-0.38 0.25-0.42 0.22-0.47 0.19-0.51 0.15-0.55 0.11-0.6 0.09-0.64 0.05-0.68 0.02-0.72-0.01-0.68-0.04-0.63-0.08-0.58-0.11-0.53-0.15-0.48-0.18-0.43-0.22-0.39-0.25-0.34-0.29-0.3-0.32-0.25-0.36-0.21-0.38-0.15-0.43-0.11-0.46-0.07-0.49-0.02-0.53v-4.71l0.02-0.51 0.07-0.49 0.11-0.45 0.16-0.42 0.2-0.39 0.26-0.35 0.29-0.32 0.34-0.29 0.39-0.25 0.43-0.22 0.47-0.18 0.52-0.15 0.56-0.12 0.61-0.08 0.65-0.06 0.7-0.01 0.69 0.01 0.65 0.06zm-2.67 1.61-0.53 0.1-0.47 0.13-0.42 0.17-0.37 0.21-0.31 0.24-0.25 0.28-0.2 0.32-0.14 0.35-0.09 0.39-0.02 0.42v4.71l0.02 0.42 0.09 0.39 0.14 0.36 0.21 0.31 0.26 0.28 0.31 0.24 0.37 0.2 0.43 0.17 0.49 0.12 0.55 0.09 0.6 0.04h0.66 0.64l0.59-0.04 0.54-0.08 0.48-0.12 0.42-0.16 0.37-0.2 0.31-0.23 0.26-0.28 0.2-0.32 0.14-0.36 0.09-0.4 0.03-0.43v-4.71l-0.03-0.42-0.09-0.39-0.14-0.35-0.2-0.32-0.27-0.28-0.31-0.24-0.38-0.21-0.44-0.17-0.49-0.13-0.55-0.1-0.62-0.05-0.67-0.02-0.63 0.02-0.58 0.05z', id: 'i' }), _jsx("path", { d: 'm181.88 0.18c0.12 0.11 0.23 0.28 0.23 0.45 0 0.29-0.34 0.68-0.97 1.32-2.62 2.53-3.94 5.62-3.93 9.36 0.01 4.12 1.4 7.44 4.1 10.01 0.56 0.5 0.8 0.9 0.8 1.24 0 0.17-0.12 0.35-0.23 0.51-0.11 0.12-0.34 0.24-0.51 0.24-0.63 0-1.5-0.74-2.64-2.18-2.22-2.72-3.22-5.72-3.28-9.82-0.05-4.1 1.23-6.88 3.75-9.75 0.9-1.02 1.66-1.56 2.17-1.56 0.17 0 0.34 0.06 0.51 0.18z', id: 'f' }), _jsx("path", { d: 'm149.59 6.94c0.45-0.57 0.85-0.92 1.25-1.08 0.39-0.23 0.96-0.34 1.6-0.34 1.96 0 2.98 0.96 2.98 2.85v9.29c0 0.79-0.28 1.14-0.85 1.14s-0.8-0.35-0.8-1.14v-8.7c0-1.19-0.51-1.83-1.49-1.83-0.74 0-1.5 0.45-2.12 1.32v9.29c0 0.79-0.29 1.13-0.8 1.13s-0.8-0.34-0.8-1.13v-8.59c0-1.33-0.56-1.95-1.61-1.95-0.68 0-1.32 0.46-2 1.33v9.22c0 0.8-0.29 1.14-0.86 1.14s-0.79-0.34-0.79-1.14v-11.38c0-0.57 0.22-0.8 0.68-0.8 0.23 0 0.45 0.17 0.57 0.51 0.11 0.15 0.17 0.44 0.17 0.78 0.53-0.57 0.87-0.91 1.02-1.03 0.34-0.22 0.8-0.34 1.44-0.34 0.91 0 1.72 0.46 2.41 1.45z', id: 'g' }), _jsx("path", { d: 'm49.79 5.56 0.44 0.05 0.41 0.08 0.4 0.09 0.37 0.12 0.35 0.14 0.33 0.16 0.31 0.18 0.28 0.21 0.27 0.22 0.24 0.24 0.22 0.27 0.2 0.28 0.18 0.31 0.16 0.33 0.13 0.35 0.11 0.37 0.1 0.39 0.07 0.41 0.06 0.44 0.03 0.45 0.01 0.47v0.12l-0.01 0.11-0.01 0.1-0.03 0.2-0.02 0.09-0.03 0.09-0.02 0.08-0.08 0.15-0.03 0.06-0.1 0.12-0.05 0.05-0.06 0.05-0.05 0.03-0.07 0.04-0.07 0.03-0.14 0.04-0.08 0.01-0.09 0.01h-8.89v-0.07h-0.02v1.84l0.01 0.24 0.02 0.24 0.04 0.23 0.06 0.22 0.06 0.2 0.09 0.2 0.1 0.17 0.12 0.18 0.14 0.15 0.15 0.16 0.16 0.13 0.18 0.13 0.2 0.11 0.22 0.11 0.22 0.09 0.25 0.09 0.26 0.06 0.28 0.07 0.29 0.05 0.31 0.03 0.33 0.03 0.34 0.02 0.36 0.01h0.13l0.13-0.01h0.12l0.13-0.01h0.12l0.24-0.02 0.23-0.02 0.11-0.01 0.12-0.02 0.31-0.04 0.1-0.02 0.09-0.02 0.1-0.02 0.09-0.02 0.1-0.03 0.09-0.02 0.16-0.06 0.09-0.03 0.04-0.01 0.05-0.02 0.06-0.02 0.05-0.02 0.05-0.03 0.06-0.02 0.06-0.03 0.07-0.03 0.14-0.07 0.14-0.08 0.08-0.05 0.08-0.04 0.08-0.05 0.18-0.1 0.08-0.06 0.19-0.13 0.1-0.06 0.11-0.07 0.1-0.07 0.13-0.12 0.13-0.1 0.05-0.05 0.06-0.04 0.05-0.04 0.06-0.05 0.09-0.07 0.09-0.06 0.05-0.03 0.04-0.02 0.04-0.03 0.07-0.03 0.09-0.03 0.05-0.01h0.09l0.1 0.01 0.15 0.03 0.04 0.02 0.07 0.04 0.04 0.03 0.09 0.09 0.03 0.04 0.04 0.08 0.02 0.1 0.02 0.06v0.05l0.01 0.05v0.07l0.01 0.07-0.01 0.06v0.07l-0.01 0.06-0.01 0.07-0.07 0.2-0.03 0.06-0.06 0.14-0.09 0.14-0.05 0.06-0.11 0.14-0.12 0.13-0.14 0.14-0.16 0.14-0.08 0.06-0.08 0.08-0.15 0.1-0.15 0.11-0.32 0.2-0.17 0.09-0.18 0.09-0.18 0.08-0.19 0.07-0.2 0.08-0.2 0.07-0.21 0.06-0.22 0.06-0.44 0.1-0.47 0.09-0.5 0.06-0.52 0.04-0.55 0.02h-0.28-0.49l-0.47-0.03-0.44-0.04-0.43-0.07-0.4-0.07-0.38-0.09-0.36-0.11-0.34-0.13-0.32-0.15-0.29-0.16-0.27-0.18-0.26-0.2-0.23-0.21-0.21-0.23-0.19-0.25-0.16-0.26-0.14-0.29-0.12-0.29-0.1-0.32-0.08-0.34-0.06-0.35-0.03-0.37-0.01-0.39v-4.71l0.01-0.14 0.01-0.12 0.06-0.39 0.03-0.13 0.03-0.12 0.05-0.12 0.08-0.24 0.06-0.12 0.11-0.23 0.07-0.11 0.07-0.12 0.07-0.1 0.08-0.11 0.09-0.1 0.09-0.11 0.18-0.2 0.1-0.1 0.14-0.13 0.29-0.24 0.32-0.22 0.17-0.1 0.36-0.18 0.38-0.16 0.2-0.07 0.42-0.12 0.44-0.1 0.23-0.04 0.23-0.03 0.24-0.04 0.24-0.02 0.26-0.02 0.52-0.02h0.26l0.48 0.01 0.46 0.03zm-2.04 1.6-0.4 0.07-0.4 0.1-0.38 0.13-0.36 0.15-0.35 0.18-0.34 0.25-0.28 0.26-0.23 0.28-0.17 0.3-0.13 0.31-0.08 0.32-0.02 0.35v0.96h8.18l-0.08-0.98-0.26-0.83-0.42-0.69-0.6-0.53-0.77-0.38-0.94-0.23-1.11-0.07-0.44 0.01-0.42 0.04z', id: 'h' }), _jsx("path", { d: 'm67.34 1.76c1.14 0.86 1.73 2.07 1.73 3.55 0 0.68-0.29 1.02-0.86 1.02-0.4 0-0.68-0.34-0.85-1.02-0.12-0.57-0.34-1.08-0.62-1.62-0.52-0.9-1.61-1.32-3.32-1.32-1.5 0-2.52 0.34-3.14 1.08-0.57 0.68-0.92 1.72-0.92 3.26v5.95c0 1.55 0.35 2.63 0.97 3.31 0.68 0.74 1.73 1.13 3.21 1.13 2.23 0 3.54-0.79 3.82-2.34 0.11-0.57 0.17-0.86 0.17-0.91 0.11-0.34 0.34-0.51 0.68-0.51 0.57 0 0.86 0.34 0.86 1.02 0 1.44-0.58 2.52-1.79 3.38-0.96 0.62-2.18 0.96-3.77 0.96-1.83 0-3.25-0.4-4.3-1.25-1.21-0.8-1.72-2.16-1.72-3.94v-7.16c0-3.77 1.93-5.61 5.95-5.61 1.61 0 2.86 0.34 3.9 1.02z', id: 'o' }), _jsx("path", { d: 'm158.79 5.43 0.12 0.04 0.03 0.02 0.03 0.01 0.03 0.03 0.06 0.04 0.03 0.03 0.03 0.02 0.1 0.14 0.06 0.08 0.02 0.05 0.02 0.04 0.01 0.04 0.02 0.09 0.01 0.04 0.02 0.04 0.01 0.05 0.01 0.04 0.02 0.04 0.01 0.05 0.05 0.12 0.02 0.05 0.03 0.08 0.01 0.05 0.03 0.12 0.02 0.04 0.02 0.08 0.25-0.12 0.49-0.23 0.24-0.11 0.23-0.1 0.46-0.18 0.22-0.08 0.21-0.08 0.21-0.07 0.2-0.06 0.2-0.07 0.2-0.05 0.38-0.09 0.19-0.04 0.34-0.06 0.17-0.02 0.32-0.02h0.16l0.46 0.01 0.44 0.03 0.43 0.04 0.39 0.06 0.38 0.09 0.36 0.1 0.33 0.12 0.31 0.14 0.29 0.15 0.27 0.17 0.25 0.2 0.22 0.2 0.21 0.23 0.18 0.24 0.16 0.26 0.14 0.28 0.12 0.3 0.09 0.31 0.08 0.33 0.06 0.35 0.03 0.36 0.01 0.38v4.76l-0.01 0.19-0.01 0.18-0.01 0.17-0.03 0.18-0.02 0.17-0.08 0.32-0.05 0.16-0.05 0.15-0.12 0.3-0.08 0.14-0.07 0.14-0.09 0.14-0.09 0.13-0.09 0.12-0.1 0.13-0.11 0.12-0.12 0.12-0.11 0.12-0.26 0.22-0.24 0.18-0.26 0.16-0.14 0.07-0.13 0.07-0.15 0.07-0.15 0.06-0.15 0.05-0.32 0.11-0.33 0.08-0.17 0.04-0.36 0.06-0.36 0.04-0.19 0.02-0.2 0.01h-0.19-0.44l-0.24-0.01-0.24-0.02-0.23-0.01-0.46-0.06-0.22-0.04-0.44-0.09-0.21-0.06-0.21-0.05-0.21-0.07-0.21-0.08-0.4-0.16-0.2-0.1-0.19-0.1-0.2-0.1-0.19-0.11-0.19-0.12-0.36-0.26v5.49l-0.02 0.2-0.01 0.09-0.02 0.09-0.06 0.24-0.03 0.07-0.04 0.07-0.03 0.05-0.04 0.06-0.08 0.1-0.1 0.08-0.06 0.02-0.05 0.03-0.06 0.02-0.06 0.01-0.07 0.01h-0.14-0.06l-0.07-0.02-0.05-0.02-0.06-0.02-0.11-0.06-0.04-0.04-0.05-0.04-0.08-0.1-0.04-0.06-0.03-0.06-0.02-0.07-0.03-0.08-0.04-0.16-0.02-0.1-0.01-0.09-0.01-0.11v-0.1-16.31-0.1l0.01-0.09 0.01-0.1 0.01-0.09 0.01-0.08 0.02-0.08 0.03-0.07 0.02-0.08 0.05-0.12 0.04-0.06 0.03-0.05 0.04-0.05 0.04-0.03 0.05-0.04 0.04-0.03 0.05-0.03 0.11-0.03 0.06-0.01h0.09l0.03 0.01h0.03zm4.49 1.74-0.46 0.04-0.22 0.03-0.21 0.03-0.21 0.05-0.38 0.1-0.36 0.14-0.15 0.07-0.16 0.08-0.15 0.1-0.17 0.11-0.16 0.12-0.17 0.14-0.17 0.15-0.18 0.16-0.18 0.18-0.2 0.19-0.2 0.2v6.54h0.06v-0.03l0.47 0.28 0.45 0.25 0.45 0.24 0.42 0.19 0.41 0.18 0.4 0.14 0.38 0.12 0.36 0.09 0.35 0.07 0.34 0.04 0.32 0.02 0.28-0.01 0.27-0.01 0.26-0.03 0.24-0.03 0.25-0.04 0.23-0.05 0.22-0.06 0.21-0.07 0.2-0.08 0.19-0.09 0.17-0.1 0.21-0.13 0.18-0.15 0.17-0.15 0.14-0.16 0.12-0.17 0.1-0.19 0.08-0.19 0.06-0.2 0.05-0.22 0.02-0.22 0.01-0.23v-4.75l-0.01-0.28-0.03-0.26-0.05-0.23-0.07-0.23-0.09-0.21-0.11-0.2-0.14-0.18-0.15-0.17-0.18-0.15-0.2-0.15-0.22-0.12-0.18-0.09-0.19-0.09-0.2-0.06-0.21-0.07-0.22-0.05-0.24-0.05-0.24-0.04-0.26-0.03-0.27-0.02-0.28-0.01-0.29-0.01-0.25 0.01h-0.26z', id: 'd' })] }), _jsx("use", { fill: '#ffffff', xlinkHref: '#k' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#k' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#j' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#j' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#b' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#b' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#n' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#n' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#l' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#l' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#c' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#c' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#e' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#e' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#m' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#m' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#a' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#a' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#i' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#i' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#f' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#f' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#g' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#g' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#h' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#h' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#o' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#o' }), _jsx("use", { fill: '#ffffff', xlinkHref: '#d' }), _jsx("use", { fillOpacity: 0, stroke: '#000000', strokeOpacity: 0, xlinkHref: '#d' })] })); +} +FreeCodeCampLogo.displayName = 'FreeCodeCampLogo'; +export default FreeCodeCampLogo; diff --git a/.freeCodeCamp/client/assets/fcc_primary_small.svg b/client/assets/fcc_primary_small.svg similarity index 100% rename from .freeCodeCamp/client/assets/fcc_primary_small.svg rename to client/assets/fcc_primary_small.svg diff --git a/.freeCodeCamp/client/components/block.tsx b/client/components/block.tsx similarity index 69% rename from .freeCodeCamp/client/components/block.tsx rename to client/components/block.tsx index e13f4f02..7288b29d 100644 --- a/.freeCodeCamp/client/components/block.tsx +++ b/client/components/block.tsx @@ -2,6 +2,7 @@ import { SelectionProps } from './selection'; import { ProjectI, Events } from '../types/index'; import { Tag } from './tag'; import { Checkmark } from './checkmark'; +import { parseMarkdown } from '../utils'; type BlockProps = { sock: SelectionProps['sock']; @@ -11,11 +12,11 @@ export const Block = ({ id, title, description, - isIntegrated, - isPublic, - numberOfLessons, - currentLesson, - completedDate, + is_integrated, + is_public, + number_of_lessons, + current_lesson, + completed_date, tags, sock }: BlockProps) => { @@ -24,22 +25,22 @@ export const Block = ({ } let lessonsCompleted = 0; - if (completedDate) { - lessonsCompleted = numberOfLessons; + if (completed_date) { + lessonsCompleted = number_of_lessons; } else { lessonsCompleted = - !isIntegrated && currentLesson === numberOfLessons - 1 - ? currentLesson + 1 - : currentLesson; + !is_integrated && current_lesson === number_of_lessons - 1 + ? current_lesson + 1 + : current_lesson; } return (
  • diff --git a/.freeCodeCamp/client/components/checkmark.tsx b/client/components/checkmark.tsx similarity index 100% rename from .freeCodeCamp/client/components/checkmark.tsx rename to client/components/checkmark.tsx diff --git a/client/components/console.tsx b/client/components/console.tsx new file mode 100644 index 00000000..fcfd415c --- /dev/null +++ b/client/components/console.tsx @@ -0,0 +1,32 @@ +import { ConsoleError, TestType } from '../types'; +import { parseMarkdown } from '../utils'; + +export const Console = ({ cons, tests }: { cons: ConsoleError[]; tests: TestType[] }) => { + return ( +
      + {cons.map((con) => { + const originalIndex = tests.findIndex(t => t.test_id === con.test_id); + return ; + })} +
    + ); +}; + +const ConsoleElement = ({ + test_text, + error, + index +}: ConsoleError & { index: number }) => { + const details = `${index + 1} ${test_text} + + \`\`\`json + ${error ? JSON.stringify(error, null, 2) : ''} + \`\`\``; + return ( +
    + ); +}; diff --git a/.freeCodeCamp/client/components/controls.tsx b/client/components/controls.tsx similarity index 87% rename from .freeCodeCamp/client/components/controls.tsx rename to client/components/controls.tsx index 2d2242c5..2d764230 100644 --- a/.freeCodeCamp/client/components/controls.tsx +++ b/client/components/controls.tsx @@ -5,7 +5,7 @@ interface ControlsProps { cancelTests: F; runTests: F; resetProject?: F; - isResetEnabled?: ProjectI['isResetEnabled']; + is_reset_enabled?: ProjectI['is_reset_enabled']; tests: TestType[]; loader?: LoaderT; } @@ -17,10 +17,10 @@ function progressStyle(loader?: LoaderT) { } const { - isLoading, + is_loading, progress: { total, count } } = loader; - if (isLoading) { + if (is_loading) { return { background: `linear-gradient(to right, #0065A9 ${ (count / total) * 100 @@ -33,14 +33,14 @@ export const Controls = ({ cancelTests, runTests, resetProject, - isResetEnabled, + is_reset_enabled, tests, loader }: ControlsProps) => { const [isTestsRunning, setIsTestsRunning] = useState(false); useEffect(() => { - if (tests.some(t => t.isLoading)) { + if (tests.some(t => t.is_loading)) { setIsTestsRunning(true); } else { setIsTestsRunning(false); @@ -55,7 +55,7 @@ export const Controls = ({ } } - const resetDisabled = !isResetEnabled || loader?.isLoading; + const resetDisabled = !is_reset_enabled || loader?.is_loading; return (
    diff --git a/.freeCodeCamp/client/components/description.tsx b/client/components/description.tsx similarity index 62% rename from .freeCodeCamp/client/components/description.tsx rename to client/components/description.tsx index 2adc8306..fbd598d8 100644 --- a/.freeCodeCamp/client/components/description.tsx +++ b/client/components/description.tsx @@ -1,3 +1,5 @@ +import { parseMarkdown } from "../utils"; + interface DescriptionProps { description: string; } @@ -6,7 +8,7 @@ export const Description = ({ description }: DescriptionProps) => { return (
    ); }; diff --git a/.freeCodeCamp/client/components/error.tsx b/client/components/error.tsx similarity index 92% rename from .freeCodeCamp/client/components/error.tsx rename to client/components/error.tsx index 2bb71998..ec16d2c7 100644 --- a/.freeCodeCamp/client/components/error.tsx +++ b/client/components/error.tsx @@ -14,7 +14,7 @@ export const E44o5 = ({
    More Info -

    {JSON.stringify(error, null, 2)}

    +

    {error.message || JSON.stringify(error, null, 2)}

    )}

    To Keep Learning:

    diff --git a/.freeCodeCamp/client/components/header.tsx b/client/components/header.tsx similarity index 100% rename from .freeCodeCamp/client/components/header.tsx rename to client/components/header.tsx diff --git a/.freeCodeCamp/client/components/heading.tsx b/client/components/heading.tsx similarity index 74% rename from .freeCodeCamp/client/components/heading.tsx rename to client/components/heading.tsx index 45877a59..fbb368ad 100644 --- a/.freeCodeCamp/client/components/heading.tsx +++ b/client/components/heading.tsx @@ -1,18 +1,19 @@ import { useEffect, useState } from 'react'; import { F } from '../types'; +import { parseMarkdown } from '../utils'; interface HeadingProps { title: string; - lessonNumber?: number; - numberOfLessons?: number; + lesson_number?: number; + number_of_lessons?: number; goToNextLesson?: F; goToPreviousLesson?: F; } export const Heading = ({ title, - lessonNumber, - numberOfLessons, + lesson_number, + number_of_lessons, goToNextLesson, goToPreviousLesson }: HeadingProps) => { @@ -21,14 +22,14 @@ export const Heading = ({ useEffect(() => { setAnim('fade-in'); setTimeout(() => setAnim(''), 1000); - }, [lessonNumber]); + }, [lesson_number]); - const lessonNumberExists = typeof lessonNumber !== 'undefined'; - const canGoBack = lessonNumberExists && lessonNumber > 0; + const lessonNumberExists = typeof lesson_number !== 'undefined'; + const canGoBack = lessonNumberExists && lesson_number > 0; const canGoForward = - lessonNumberExists && numberOfLessons && lessonNumber < numberOfLessons - 1; + lessonNumberExists && number_of_lessons && lesson_number < number_of_lessons - 1; - const h1 = title + (lessonNumberExists ? ' - Lesson ' + lessonNumber : ''); + const h1 = title + (lessonNumberExists ? ' - Lesson ' + lesson_number : ''); return (