diff --git a/.eslintrc.json b/.eslintrc.json index 06a94aa018f..b7bff541451 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -46,7 +46,9 @@ "./tsconfig.json", "./config/tsconfig.json", "./tools/ui-components/tsconfig.json", - "./utils/tsconfig.json" + "./utils/tsconfig.json", + "./web/tsconfig.json", + "./curriculum-server/tsconfig.json" ] }, "extends": [ @@ -116,6 +118,10 @@ "cy": true, "Cypress": true } + }, + { + "files": ["web/**/*.tsx"], + "extends": ["plugin:react/jsx-runtime"] } ] } diff --git a/.github/workflows/node.js-tests.yml b/.github/workflows/node.js-tests.yml index 0733218d9d7..01245394a68 100644 --- a/.github/workflows/node.js-tests.yml +++ b/.github/workflows/node.js-tests.yml @@ -42,11 +42,15 @@ jobs: echo 'SHOW_NEW_CURRICULUM=true' >> .env cat .env + # The two prefixed installs are for the client update which are not, + # currently, built as workspaces. - name: Lint Source Files run: | echo npm version $(npm -v) npm ci npm run create:config + npm i --prefix=curriculum-server + npm i --prefix=web npm run build:curriculum npm run lint diff --git a/.prettierignore b/.prettierignore index 072c0e392f9..8e56696477f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,3 +12,5 @@ docs/i18n utils/slugs.js utils/slugs.test.js **/package-lock.json +web/.next +curriculum-server/data/curriculum.json diff --git a/curriculum-server/.gitignore b/curriculum-server/.gitignore new file mode 100644 index 00000000000..1269488f7fb --- /dev/null +++ b/curriculum-server/.gitignore @@ -0,0 +1 @@ +data diff --git a/curriculum-server/package.json b/curriculum-server/package.json new file mode 100644 index 00000000000..fbfe076345b --- /dev/null +++ b/curriculum-server/package.json @@ -0,0 +1,33 @@ +{ + "name": "@freecodecamp/curriculum-server", + "version": "0.0.1", + "description": "Web server for curriculum data", + "license": "BSD-3-Clause", + "private": true, + "engines": { + "node": ">=16", + "npm": ">=8" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git" + }, + "bugs": { + "url": "https://github.com/freeCodeCamp/freeCodeCamp/issues" + }, + "homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme", + "author": "freeCodeCamp ", + "main": "none", + "scripts": { + "dev": "json-server --watch ./data/curriculum.json", + "test": "echo \"Error: no test specified\" && exit 1", + "source-curriculum": "ts-node source-curriculum.ts" + }, + "dependencies": { + "json-server": "^0.17.0" + }, + "devDependencies": { + "ts-node": "^10.7.0", + "typescript": "^4.6.3" + } +} diff --git a/curriculum-server/source-curriculum.ts b/curriculum-server/source-curriculum.ts new file mode 100644 index 00000000000..0fe2400cdf0 --- /dev/null +++ b/curriculum-server/source-curriculum.ts @@ -0,0 +1,25 @@ +import fs from 'fs/promises'; + +import curriculum from '../config/curriculum.json'; + +interface Curriculum { + [key: string]: unknown; +} + +const curriculumList = Object.keys(curriculum as Curriculum).map(key => { + if (key === '2022/responsive-web-design') { + return { '2022-responsive-web-design': (curriculum as Curriculum)[key] }; + } else { + return { [key]: (curriculum as Curriculum)[key] }; + } +}); + +const patchedCurriculum = curriculumList.reduce((prev, curr) => { + return { ...prev, ...curr }; +}, {}); + +void fs + .mkdir('data', { recursive: true }) + .then(() => + fs.writeFile('./data/curriculum.json', JSON.stringify(patchedCurriculum)) + ); diff --git a/curriculum-server/tsconfig.json b/curriculum-server/tsconfig.json new file mode 100644 index 00000000000..4ec0b8780b8 --- /dev/null +++ b/curriculum-server/tsconfig.json @@ -0,0 +1,5 @@ +{ + "include": ["**/*.ts"], + "exclude": ["node_modules"], + "extends": "../tsconfig-base.json" +} diff --git a/tsconfig-base.json b/tsconfig-base.json index f68b244accb..cea38b99a1f 100644 --- a/tsconfig-base.json +++ b/tsconfig-base.json @@ -7,6 +7,7 @@ "allowJs": true, "jsx": "react", "strict": true, + "forceConsistentCasingInFileNames": true, "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 00000000000..a680367ef56 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1 @@ +.next diff --git a/web/README.md b/web/README.md new file mode 100644 index 00000000000..731a10d38a2 --- /dev/null +++ b/web/README.md @@ -0,0 +1,41 @@ +# Getting Started + +If you haven't installed freeCodeCamp proper yet, that needs to happen first. + +Once that's done, the curriculum server and this package need installing. Then the prepare script will take care of the rest. + +```sh +cd ../curriculum-server +npm i +cd ../web +npm i +npm run dev +``` + +Now the server should be running on port 3000 and the client on port 8000. + +For now there's not much to see. + +http://localhost:8000/learn/special-path + +is the main entry point and + +http://localhost:3000/responsive-web-design + +is the curriculum data that is currently being used. + +## Things of Note + +Incremental static regeneration is working quite nicely. You can modify the curriculum data (in /curriculum-server/data/curriculum.json), refresh reload your browser and the changes will be reflected. + +The trailing ids are a bit buggy, but you can replace them with a new page's mongo id and it ~should~ will refresh. + +Also, mangled paths _mostly_ work. For example: + +http://localhost:8000/learn/responsive-web-design/applied-an-element/587d774e367417b2b2512a9f + +redirects you to + +http://localhost:8000/learn/responsive-web-design/applied-accessibility/jump-straight-to-the-content-using-the-main-element/587d774e367417b2b2512a9f + +but not all paths behave as desired. diff --git a/web/next-env.d.ts b/web/next-env.d.ts new file mode 100644 index 00000000000..4f11a03dc6c --- /dev/null +++ b/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000000..0b1bde9106e --- /dev/null +++ b/web/package.json @@ -0,0 +1,37 @@ +{ + "name": "@freecodecamp/web", + "version": "0.0.1", + "description": "The freeCodeCamp.org open-source codebase and curriculum", + "license": "BSD-3-Clause", + "private": true, + "engines": { + "node": ">=16", + "npm": ">=8" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git" + }, + "bugs": { + "url": "https://github.com/freeCodeCamp/freeCodeCamp/issues" + }, + "homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme", + "author": "freeCodeCamp ", + "main": "none", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "develop-client": "next dev --port 8000", + "dev": "concurrently \"npm:develop-curriculum-server\" \"npm:develop-client\"", + "develop-curriculum-server": "npm --prefix ../curriculum-server run dev", + "prepare": "npm --prefix ../ run build:curriculum && npm --prefix ../curriculum-server/ run source-curriculum" + }, + "dependencies": { + "@monaco-editor/react": "^4.4.2", + "next": "^12.1.5", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "concurrently": "^7.1.0" + } +} diff --git a/web/src/data-fetching/get-curriculum.ts b/web/src/data-fetching/get-curriculum.ts new file mode 100644 index 00000000000..fe62a932e83 --- /dev/null +++ b/web/src/data-fetching/get-curriculum.ts @@ -0,0 +1,165 @@ +/* + Eventually this module could be used as a cache (in memory or on-disk) for the + curriculum, but for now it just fetches the data on demand. + + A reasonably reliable approach would be as follows: + 1. Query the curriculum API for the latest version of the curriculum. + 2. If the API responds + a. If the latest version is not cached, query the API for the latest version + b. Otherwise use the cached value. + 3. If the API does not respond, use the cached value and log an error. + +*/ + +// TODO: this should be { [superblock: string]: Superblock } +export interface Curriculum { + rwdBlocks: SuperBlock; + jsBlocks: SuperBlock; +} + +export interface SuperBlock { + [index: string]: Block; +} + +export interface Block { + meta: { + name: string; + isUpcomingChange: boolean; + dashedName: string; + order: number; + time: string; + template: string; + required: string[]; + superBlock: string; + challengeOrder: [id: string, title: string][]; + }; + challenges: Challenge[]; +} + +export interface Challenge { + id: string; + dashedName: string; + description: string; + challengeFiles: { contents: string; ext: string }[]; +} + +export interface PathSegments { + superblock: string; + block?: string; + dashedName?: string; + id: string; +} + +export interface IdToDashedNameMap { + [id: string]: string; +} + +interface SuperBlockToChallengeMap { + [index: string]: (pathSegments: Required) => Challenge; +} + +export async function getCurriculum() { + const rwd = await fetch('http://localhost:3000/responsive-web-design'); + const js = await fetch( + 'http://localhost:3000/javascript-algorithms-and-data-structures' + ); + const rwdBlocks = ((await rwd.json()) as { blocks: SuperBlock }).blocks; + const jsBlocks = ((await js.json()) as { blocks: SuperBlock }).blocks; + + return { rwdBlocks, jsBlocks }; +} + +export function getIdToPathSegmentsMap({ rwdBlocks }: Curriculum) { + // TODO: this is pretty inefficient. The curriculum server needs to return an + // object with ids as keys and the superblock, block and dashedName as values. + // i.e. enough info to recreate the full path. + + // Also TODO: use params here and, instead of passing the map, just pass the + // new path. + const idToPathSegmentsMap: Record = {}; + for (const blockName of Object.keys(rwdBlocks)) { + const block = rwdBlocks[blockName]; + for (const challenge of block.challenges) { + idToPathSegmentsMap[challenge.id] = { + superblock: 'responsive-web-design', + block: blockName, + dashedName: challenge.dashedName, + id: challenge.id + }; + } + } + idToPathSegmentsMap['special-path'] = { + superblock: 'responsive-web-design', + id: 'special-path' + }; + return idToPathSegmentsMap; +} + +type SuperBlockToBlockMap = { + [superblock: string]: string[]; +}; + +export function getSuperBlockToBlockMap( + curriculum: Curriculum +): SuperBlockToBlockMap { + return { 'responsive-web-design': Object.keys(curriculum.rwdBlocks) }; +} + +export function getBlockNameToChallengeOrderMap( + { rwdBlocks }: Curriculum, + blockNames: string[] +): { [index: string]: [id: string, title: string] } { + return blockNames.reduce( + (prev, blockName) => ({ + ...prev, + ...{ [blockName]: rwdBlocks[blockName].meta.challengeOrder } + }), + {} + ); +} + +// TODO: remove the hardcoding of superblock names. Also, the map generation is +// a mess +export function getChallengeData( + { rwdBlocks, jsBlocks }: Curriculum, + pathSegments: Required +) { + const superBlockToChallengeMap: SuperBlockToChallengeMap = { + 'responsive-web-design': (pathSegments: Required) => + findChallenge(findBlock(rwdBlocks, pathSegments), pathSegments), + 'javascript-algorithms-and-data-structures': ( + pathSegments: Required + ) => findChallenge(findBlock(jsBlocks, pathSegments), pathSegments) + }; + return superBlockToChallengeMap[pathSegments.superblock](pathSegments); +} + +function findBlock(superblock: SuperBlock, params: Required) { + return superblock[params.block]; +} + +function findChallenge(block: Block, params: PathSegments) { + const challenge = block.challenges.find( + (c: { dashedName: string }) => c.dashedName == params.dashedName + ); + // TODO: is there a nicer way to handle missing challenges? + if (!challenge) { + throw new Error(`Challenge not found: ${params.id}`); + } + return challenge; +} + +// TODO: again, bit ugly. Would be better to get data in this shape from the +// curriculum server. +export function getIdToDashedNameMap({ + rwdBlocks +}: Curriculum): IdToDashedNameMap { + const idToDashedNameMap: Record = {}; + for (const blockName of Object.keys(rwdBlocks)) { + const block = rwdBlocks[blockName]; + for (const challenge of block.challenges) { + idToDashedNameMap[challenge.id] = challenge.dashedName; + } + } + return idToDashedNameMap; +} diff --git a/web/src/page-templates/challenge.tsx b/web/src/page-templates/challenge.tsx new file mode 100644 index 00000000000..b0f393271ed --- /dev/null +++ b/web/src/page-templates/challenge.tsx @@ -0,0 +1,48 @@ +import { InferGetStaticPropsType } from 'next'; +import { useRouter } from 'next/router'; +import Editor from '@monaco-editor/react'; +import Link from 'next/link'; + +import type { + getStaticProps, + Challenge +} from '../pages/learn/[superblock]/[blockOrId]/[dashedName]/[id]'; + +export default function ChallengeComponent({ + challengeData +}: InferGetStaticPropsType) { + const { isFallback } = useRouter(); + if (isFallback) return
Loading...
; + + return ( + <> +
+ + Go here + + + ); +} + +interface MainProps { + challengeData: Challenge | null; +} + +function Main({ challengeData }: MainProps) { + if (!challengeData || !challengeData?.challengeFiles) return null; + + return ( + <> +
+ + + ); +} diff --git a/web/src/page-templates/superblock.tsx b/web/src/page-templates/superblock.tsx new file mode 100644 index 00000000000..d851dfc3e6e --- /dev/null +++ b/web/src/page-templates/superblock.tsx @@ -0,0 +1,37 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +import { InferGetStaticPropsType } from 'next'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; + +// This is circular, but it's only types. +import type { getStaticProps } from '../pages/learn/[superblock]/[blockOrId]'; + +export default function SuperBlock({ + blockNames, + blockNameToChallengeOrderMap, + idToDashedNameMap +}: InferGetStaticPropsType) { + const { isFallback } = useRouter(); + if (isFallback) return
Loading...
; + + return ( + <> + {blockNames.map(blockName => ( +
    + {blockName} +
      + {blockNameToChallengeOrderMap[blockName].map(([id, title]) => ( +
    • + + {title} + +
    • + ))} +
    +
+ ))} + + ); +} diff --git a/web/src/pages/learn/[...id].tsx b/web/src/pages/learn/[...id].tsx new file mode 100644 index 00000000000..11e6d89efee --- /dev/null +++ b/web/src/pages/learn/[...id].tsx @@ -0,0 +1,56 @@ +import { GetStaticPaths, GetStaticProps } from 'next'; +import { + getCurriculum, + getIdToPathSegmentsMap, + PathSegments +} from '../../data-fetching/get-curriculum'; + +// Next expects there to be a React component to render the page. This never +// happens because getStaticProps either redirects to a 404 or to another page, +// but we still need to provide something: +export default function Catch() { + return null; +} + +export const getStaticProps: GetStaticProps = async ({ params }) => { + const idToPathSegmentsMap = getIdToPathSegmentsMap(await getCurriculum()); + const uuid = params?.id?.slice(-1)[0]; + + if (!uuid) { + return { notFound: true, revalidate: 10 }; + } + // TODO: rather than using path segments, use the whole path. This makes this + // more generic and it's easier to redirect to non-challenge pages. i.e. if we + // have the id of a superblock, it won't have three path segments - it will have + // one. + const pathSegments = idToPathSegmentsMap[uuid]; + if (!pathSegments) { + return { notFound: true, revalidate: 10 }; + } + + return { + redirect: { + destination: getDestination(pathSegments), + permanent: false + }, + revalidate: 10 + }; +}; + +export const getDestination = (pathSegments: PathSegments) => { + const { superblock, block, dashedName, id } = pathSegments; + // Currently there are either + if (block && dashedName) { + // challenges: + return `/learn/${superblock}/${block}/${dashedName}/${id}`; + } else { + // or superblocks: + return `/learn/${superblock}/${id}`; + } +}; + +// As with the page component, even though we render 0 pages, this has to exist +export const getStaticPaths: GetStaticPaths = () => ({ + paths: [], + fallback: true +}); diff --git a/web/src/pages/learn/[superblock]/[blockOrId].tsx b/web/src/pages/learn/[superblock]/[blockOrId].tsx new file mode 100644 index 00000000000..0f24e6a1d99 --- /dev/null +++ b/web/src/pages/learn/[superblock]/[blockOrId].tsx @@ -0,0 +1,93 @@ +import { ParsedUrlQuery } from 'querystring'; +import { GetStaticPaths, GetStaticProps } from 'next'; +import { + getCurriculum, + getIdToDashedNameMap, + getIdToPathSegmentsMap, + PathSegments, + getSuperBlockToBlockMap, + getBlockNameToChallengeOrderMap, + Curriculum +} from '../../../data-fetching/get-curriculum'; +import SuperBlock from '../../../page-templates/superblock'; +import { getDestination } from '../[...id]'; + +interface Props { + blockNames: string[]; + blockNameToChallengeOrderMap: { + [index: string]: [id: string, title: string]; + }; + idToDashedNameMap: { [index: string]: string }; +} + +export default SuperBlock; + +export const getStaticProps: GetStaticProps = async ({ params }) => { + const curriculum = await getCurriculum(); + const idToPathSegmentsMap = getIdToPathSegmentsMap(curriculum); + + // TODO: simplify once noUncheckedIndexedAccess is set. + const pathSegments = idToPathSegmentsMap[params?.blockOrId as string] as + | PathSegments + | undefined; + + if (!pathSegments) return fourOhFour(); + if (pathExists(pathSegments, params)) { + const props = getProps(curriculum); + return renderPage(props); + } else { + return redirect(pathSegments); + } +}; + +const getProps = (curriculum: Curriculum) => { + const idToDashedNameMap = getIdToDashedNameMap(curriculum); + const superBlockToBlockMap = getSuperBlockToBlockMap(curriculum); + + // TODO: figure out how to generate string literal types for these. I think + // the approach has to be to fetch the curriculum and use that to generate a + // type declaration. This won't mean anything in production, but it will be + // helpful when developing. + const blockNames = superBlockToBlockMap['responsive-web-design']; + const blockNameToChallengeOrderMap = getBlockNameToChallengeOrderMap( + curriculum, + blockNames + ); + + return { + blockNames, + blockNameToChallengeOrderMap, + idToDashedNameMap + }; +}; + +const renderPage = (props: Props) => ({ + props, + revalidate: 10 +}); + +const redirect = (pathSegments: PathSegments) => ({ + redirect: { + destination: getDestination(pathSegments), + permanent: false + }, + revalidate: 10 +}); + +// DRY this with [id]'s version +const fourOhFour = () => ({ notFound: true, revalidate: 10 } as const); + +// DRY this with [id]'s version +const pathExists = (pathSegments: PathSegments, params?: ParsedUrlQuery) => { + const isChallenge = pathSegments.dashedName; + const isExpectedSuperBlockParam = + params?.superblock === pathSegments.superblock; + return !isChallenge && isExpectedSuperBlockParam; +}; + +export const getStaticPaths: GetStaticPaths = () => { + return { + paths: ['/learn/responsive-web-design/special-path'], + fallback: true + }; +}; diff --git a/web/src/pages/learn/[superblock]/[blockOrId]/[dashedName]/[id].tsx b/web/src/pages/learn/[superblock]/[blockOrId]/[dashedName]/[id].tsx new file mode 100644 index 00000000000..c84104d88b3 --- /dev/null +++ b/web/src/pages/learn/[superblock]/[blockOrId]/[dashedName]/[id].tsx @@ -0,0 +1,122 @@ +import { ParsedUrlQuery } from 'querystring'; +import { GetStaticPaths, GetStaticProps } from 'next'; + +import { + SuperBlock, + Challenge, + getCurriculum, + getIdToPathSegmentsMap, + PathSegments, + getChallengeData, + Curriculum +} from '../../../../../data-fetching/get-curriculum'; +import ChallengeComponent from '../../../../../page-templates/challenge'; +import { getDestination } from '../../../[...id]'; +interface Props { + challengeData: Challenge; +} + +export type { Challenge }; + +export default ChallengeComponent; + +export const getStaticProps: GetStaticProps = async ({ params }) => { + const curriculum = await getCurriculum(); + const idToPathSegmentsMap = getIdToPathSegmentsMap(curriculum); + + // TODO: simplify once noUncheckedIndexedAccess is set. + const pathSegments = idToPathSegmentsMap[params?.id as string] as + | PathSegments + | undefined; + + if (!pathSegments) return fourOhFour(); + if (pathExists(pathSegments, params)) { + const props = getProps(curriculum, pathSegments); + return renderPage(props); + } else { + return redirect(pathSegments); + } +}; + +const getProps = ( + curriculum: Curriculum, + pathSegments: Required +) => ({ + challengeData: getChallengeData(curriculum, pathSegments) +}); +// DRY this with [blockOrId]'s version +const fourOhFour = () => ({ notFound: true, revalidate: 10 } as const); + +// DRY this with [blockOrId]'s version +const pathExists = ( + pathSegments: PathSegments, + params?: ParsedUrlQuery +): pathSegments is Required => + params?.superblock === pathSegments.superblock && + params?.blockOrId === pathSegments.block && + params?.dashedName === pathSegments.dashedName; + +const renderPage = (props: Props) => ({ + props, + revalidate: 10 +}); + +function redirect(pathSegments: PathSegments) { + return { + redirect: { + destination: getDestination(pathSegments), + permanent: false + }, + revalidate: 10 + }; +} + +export const getStaticPaths: GetStaticPaths = async () => { + const { rwdBlocks } = await getCurriculum(); + + const rwdBlocknames = Object.keys(rwdBlocks); + + // TODO: generalize to all superblocks... OR consider the merits of avoiding + // this entirely. If we skip this the pro is quicker builds and the con is + // that we'd more work onto the webserver. It's probably best to do as much + // work upfront as possible. At least until that upfront work takes too long. + const rwdPaths = rwdBlocknames + .map(name => + rwdBlocks[name].meta.challengeOrder.map(([id]) => + toParams( + 'responsive-web-design', + name, + getDashedName(rwdBlocks, name, id), + id + ) + ) + ) + .flat(); + + return { + paths: rwdPaths, + fallback: true + }; +}; + +function getDashedName(block: SuperBlock, blockName: string, id: string) { + const challenge = block[blockName].challenges.find(c => c.id === id); + if (!challenge) throw Error(`Challenge ${id} not found in ${blockName}`); + return challenge.dashedName; +} + +function toParams( + superblock: string, + block: string, + dashedName: string, + id: string +) { + return { + params: { + superblock, + blockOrId: block, + dashedName, + id + } + }; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 00000000000..edf1ebe902e --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "incremental": true, + "module": "esnext", + "isolatedModules": true, + "jsx": "preserve" + // "noUncheckedIndexedAccess": true TODO: add this when you've got time to clean up the code + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"], + "extends": "../tsconfig-base.json" +}