From 5fd48005e3d9cac2ff0cb821eb69242fffcc5ce4 Mon Sep 17 00:00:00 2001 From: Florian Dieminger Date: Tue, 27 Feb 2024 16:30:57 +0100 Subject: [PATCH] feat(curriculum): integrate MDN Curriculum (#10433) Integrate the [MDN Curriculum](https://github.com/mdn/curriculum) repo. --------- Co-authored-by: Claas Augner Co-authored-by: Leo McArdle --- .github/workflows/prod-build.yml | 9 + .github/workflows/stage-build.yml | 10 + .github/workflows/xyz-build.yml | 9 + build/build-curriculum.ts | 4 + build/curriculum.ts | 426 ++++++++++++++ build/extract-sections.ts | 9 - build/utils.ts | 42 +- .../curriculum-landing-stairway-1.svg | 42 ++ .../curriculum-landing-stairway-2-small.svg | 56 ++ .../curriculum-landing-stairway-2.svg | 56 ++ .../curriculum/curriculum-landing-top.svg | 95 +++ .../curriculum/curriculum-topic-practices.svg | 5 + .../curriculum/curriculum-topic-scripting.svg | 5 + .../curriculum/curriculum-topic-standards.svg | 5 + .../curriculum/curriculum-topic-styling.svg | 5 + .../curriculum/curriculum-topic-tooling.svg | 5 + client/src/app.tsx | 11 +- .../assets/icons/curriculum-about-covered.svg | 25 + .../assets/icons/curriculum-about-detail.svg | 25 + .../icons/curriculum-about-educators.svg | 25 + .../src/assets/icons/curriculum-about-not.svg | 25 + .../icons/curriculum-about-students.svg | 25 + client/src/assets/icons/curriculum-bullet.svg | 27 + .../assets/icons/curriculum-ext-resource.svg | 12 + .../curriculum-landing-about-beginner.svg | 25 + .../icons/curriculum-landing-about-bullet.svg | 27 + .../icons/curriculum-landing-about-free.svg | 25 + .../icons/curriculum-landing-about-pace.svg | 25 + .../curriculum-landing-started-advanced.svg | 37 ++ .../curriculum-landing-started-beginner.svg | 37 ++ .../curriculum-landing-started-educator.svg | 37 ++ .../curriculum-landing-started-employment.svg | 37 ++ .../assets/icons/curriculum-mdn-resource.svg | 27 + .../icons/curriculum-modules-underline.svg | 29 + client/src/assets/icons/curriculum-next.svg | 3 + client/src/assets/icons/curriculum-prev.svg | 3 + .../src/assets/icons/curriculum-resources.svg | 12 + .../icons/curriculum-started-underline.svg | 5 + client/src/curriculum/about.scss | 44 ++ client/src/curriculum/about.tsx | 26 + client/src/curriculum/body.tsx | 19 + client/src/curriculum/index.scss | 268 +++++++++ client/src/curriculum/index.tsx | 23 + client/src/curriculum/landing.scss | 553 ++++++++++++++++++ client/src/curriculum/landing.tsx | 133 +++++ client/src/curriculum/layout.tsx | 76 +++ client/src/curriculum/module.scss | 55 ++ client/src/curriculum/module.tsx | 28 + client/src/curriculum/modules-list.scss | 239 ++++++++ client/src/curriculum/modules-list.tsx | 78 +++ client/src/curriculum/overview.tsx | 34 ++ client/src/curriculum/prev-next.scss | 12 + client/src/curriculum/prev-next.tsx | 32 + client/src/curriculum/sidebar.tsx | 88 +++ client/src/curriculum/topic-icon.scss | 9 + client/src/curriculum/topic-icon.tsx | 30 + client/src/curriculum/utils.ts | 77 +++ client/src/document/hooks.ts | 3 + client/src/document/ingredients/prose.tsx | 6 +- client/src/document/ingredients/utils.tsx | 30 +- .../src/document/organisms/sidebar/index.tsx | 14 +- client/src/document/organisms/toc/index.tsx | 10 +- client/src/document/preloading.tsx | 20 - client/src/placement-context.tsx | 2 +- client/src/ui/atoms/icon/index.scss | 24 +- client/src/ui/base/_themes.scss | 132 +++++ client/src/ui/molecules/breadcrumbs/index.tsx | 7 +- client/src/ui/molecules/main-menu/index.tsx | 3 + .../article-actions-container/index.tsx | 22 +- cloud-function/src/app.ts | 2 +- deployer/src/deployer/search/__init__.py | 5 + docs/envvars.md | 6 + kumascript/index.ts | 4 +- libs/constants/index.d.ts | 1 + libs/constants/index.js | 1 + libs/env/index.d.ts | 1 + libs/env/index.js | 12 +- libs/types/curriculum.ts | 71 +++ libs/types/document.ts | 11 +- libs/types/hydration.ts | 4 +- package.json | 1 + server/index.ts | 16 + testing/tests/headless.index.spec.ts | 2 +- testing/tests/index.test.ts | 5 - 84 files changed, 3418 insertions(+), 108 deletions(-) create mode 100644 build/build-curriculum.ts create mode 100644 build/curriculum.ts create mode 100644 client/public/assets/curriculum/curriculum-landing-stairway-1.svg create mode 100644 client/public/assets/curriculum/curriculum-landing-stairway-2-small.svg create mode 100644 client/public/assets/curriculum/curriculum-landing-stairway-2.svg create mode 100644 client/public/assets/curriculum/curriculum-landing-top.svg create mode 100644 client/public/assets/curriculum/curriculum-topic-practices.svg create mode 100644 client/public/assets/curriculum/curriculum-topic-scripting.svg create mode 100644 client/public/assets/curriculum/curriculum-topic-standards.svg create mode 100644 client/public/assets/curriculum/curriculum-topic-styling.svg create mode 100644 client/public/assets/curriculum/curriculum-topic-tooling.svg create mode 100644 client/src/assets/icons/curriculum-about-covered.svg create mode 100644 client/src/assets/icons/curriculum-about-detail.svg create mode 100644 client/src/assets/icons/curriculum-about-educators.svg create mode 100644 client/src/assets/icons/curriculum-about-not.svg create mode 100644 client/src/assets/icons/curriculum-about-students.svg create mode 100644 client/src/assets/icons/curriculum-bullet.svg create mode 100644 client/src/assets/icons/curriculum-ext-resource.svg create mode 100644 client/src/assets/icons/curriculum-landing-about-beginner.svg create mode 100644 client/src/assets/icons/curriculum-landing-about-bullet.svg create mode 100644 client/src/assets/icons/curriculum-landing-about-free.svg create mode 100644 client/src/assets/icons/curriculum-landing-about-pace.svg create mode 100644 client/src/assets/icons/curriculum-landing-started-advanced.svg create mode 100644 client/src/assets/icons/curriculum-landing-started-beginner.svg create mode 100644 client/src/assets/icons/curriculum-landing-started-educator.svg create mode 100644 client/src/assets/icons/curriculum-landing-started-employment.svg create mode 100644 client/src/assets/icons/curriculum-mdn-resource.svg create mode 100644 client/src/assets/icons/curriculum-modules-underline.svg create mode 100644 client/src/assets/icons/curriculum-next.svg create mode 100644 client/src/assets/icons/curriculum-prev.svg create mode 100644 client/src/assets/icons/curriculum-resources.svg create mode 100644 client/src/assets/icons/curriculum-started-underline.svg create mode 100644 client/src/curriculum/about.scss create mode 100644 client/src/curriculum/about.tsx create mode 100644 client/src/curriculum/body.tsx create mode 100644 client/src/curriculum/index.scss create mode 100644 client/src/curriculum/index.tsx create mode 100644 client/src/curriculum/landing.scss create mode 100644 client/src/curriculum/landing.tsx create mode 100644 client/src/curriculum/layout.tsx create mode 100644 client/src/curriculum/module.scss create mode 100644 client/src/curriculum/module.tsx create mode 100644 client/src/curriculum/modules-list.scss create mode 100644 client/src/curriculum/modules-list.tsx create mode 100644 client/src/curriculum/overview.tsx create mode 100644 client/src/curriculum/prev-next.scss create mode 100644 client/src/curriculum/prev-next.tsx create mode 100644 client/src/curriculum/sidebar.tsx create mode 100644 client/src/curriculum/topic-icon.scss create mode 100644 client/src/curriculum/topic-icon.tsx create mode 100644 client/src/curriculum/utils.ts create mode 100644 libs/types/curriculum.ts diff --git a/.github/workflows/prod-build.yml b/.github/workflows/prod-build.yml index 67888f2121f9..a2014532ea27 100644 --- a/.github/workflows/prod-build.yml +++ b/.github/workflows/prod-build.yml @@ -89,6 +89,12 @@ jobs: lfs: true token: ${{ secrets.MDN_STUDIO_PAT }} + - uses: actions/checkout@v4 + if: ${{ ! vars.SKIP_BUILD }} + with: + repository: mdn/curriculum + path: mdn/curriculum + # Our usecase is a bit complicated. When the cron schedule runs this workflow, # we rely on the env vars defined at the top of the file. But if it's a manual # trigger we rely on the inputs and only the inputs. That way, the user can @@ -278,6 +284,9 @@ jobs: # Build the blog yarn build:blog + # Build the curriculum + yarn build:curriculum + # Generate whatsdeployed files. yarn tool whatsdeployed --output client/build/_whatsdeployed/code.json yarn tool whatsdeployed $CONTENT_ROOT --output client/build/_whatsdeployed/content.json diff --git a/.github/workflows/stage-build.yml b/.github/workflows/stage-build.yml index febb1bcc9b0a..ddcb2f8a7961 100644 --- a/.github/workflows/stage-build.yml +++ b/.github/workflows/stage-build.yml @@ -89,6 +89,12 @@ jobs: lfs: true token: ${{ secrets.MDN_STUDIO_PAT }} + - uses: actions/checkout@v4 + if: ${{ ! vars.SKIP_BUILD }} + with: + repository: mdn/curriculum + path: mdn/curriculum + # Our usecase is a bit complicated. When the cron schedule runs this workflow, # we rely on the env vars defined at the top of the file. But if it's a manual # trigger we rely on the inputs and only the inputs. That way, the user can @@ -169,6 +175,7 @@ jobs: CONTENT_TRANSLATED_ROOT: ${{ github.workspace }}/mdn/translated-content/files CONTRIBUTOR_SPOTLIGHT_ROOT: ${{ github.workspace }}/mdn/mdn-contributor-spotlight/contributors BLOG_ROOT: ${{ github.workspace }}/mdn/mdn-studio/content/posts + CURRICULUM_ROOT: ${{ github.workspace }}/mdn/curriculum BASE_URL: "https://developer.allizom.org" # The default for this environment variable is geared for writers @@ -271,6 +278,9 @@ jobs: # Build the blog yarn build:blog + # Build the curriculum + yarn build:curriculum + # Generate whatsdeployed files. yarn tool whatsdeployed --output client/build/_whatsdeployed/code.json yarn tool whatsdeployed $CONTENT_ROOT --output client/build/_whatsdeployed/content.json diff --git a/.github/workflows/xyz-build.yml b/.github/workflows/xyz-build.yml index 48f000fea338..d316acc2eda8 100644 --- a/.github/workflows/xyz-build.yml +++ b/.github/workflows/xyz-build.yml @@ -60,6 +60,12 @@ jobs: lfs: true token: ${{ secrets.MDN_STUDIO_PAT }} + - uses: actions/checkout@v4 + if: ${{ ! vars.SKIP_BUILD }} + with: + repository: mdn/curriculum + path: mdn/curriculum + - uses: actions/checkout@v4 if: ${{ ! vars.SKIP_BUILD || ! vars.SKIP_FUNCTION }} with: @@ -190,6 +196,9 @@ jobs: # Build the blog yarn build:blog + # Build the curriculum + yarn build:curriculum + # Generate whatsdeployed files. yarn tool whatsdeployed --output client/build/_whatsdeployed/code.json yarn tool whatsdeployed $CONTENT_ROOT --output client/build/_whatsdeployed/content.json diff --git a/build/build-curriculum.ts b/build/build-curriculum.ts new file mode 100644 index 000000000000..af8f1303f1e2 --- /dev/null +++ b/build/build-curriculum.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { buildCurriculum } from "./curriculum.js"; + +buildCurriculum({ verbose: true }); diff --git a/build/curriculum.ts b/build/curriculum.ts new file mode 100644 index 000000000000..e154ac763077 --- /dev/null +++ b/build/curriculum.ts @@ -0,0 +1,426 @@ +import path from "node:path"; +import fs from "node:fs/promises"; + +import { fdir } from "fdir"; +import frontmatter from "front-matter"; + +import { BUILD_OUT_ROOT, CURRICULUM_ROOT } from "../libs/env/index.js"; +import { DocParent } from "../libs/types/document.js"; +import { CURRICULUM_TITLE, DEFAULT_LOCALE } from "../libs/constants/index.js"; +import * as kumascript from "../kumascript/index.js"; +import LANGUAGES_RAW from "../libs/languages/index.js"; +import { syntaxHighlight } from "./syntax-highlight.js"; +import { + escapeRegExp, + injectLoadingLazyAttributes, + injectNoTranslate, + makeTOC, + postLocalFileLinks, + postProcessCurriculumLinks, + postProcessExternalLinks, + postProcessSmallerHeadingIDs, +} from "./utils.js"; +import { wrapTables } from "./wrap-tables.js"; +import { extractSections } from "./extract-sections.js"; +import { + CurriculumFrontmatter, + CurriculumData, + CurriculumMetaData, + CurriculumIndexEntry, + PrevNext, + Template, + CurriculumDoc, + ReadCurriculum, + CurriculumBuildData, +} from "../libs/types/curriculum.js"; +import { HydrationData } from "../libs/types/hydration.js"; +import { memoize, slugToFolder } from "../content/utils.js"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { renderHTML } from "../ssr/dist/main.js"; +import { CheerioAPI } from "cheerio"; + +export const allFiles: () => string[] = memoize(async () => { + const api = new fdir() + .withFullPaths() + .withErrors() + .filter((filePath) => filePath.endsWith(".md")) + .crawl(path.join(CURRICULUM_ROOT, "curriculum")); + return (await api.withPromise()).sort(); +}); + +export const buildIndex: () => CurriculumMetaData[] = memoize(async () => { + const files = await allFiles(); + const modules = await Promise.all( + files.map( + async (file) => + ( + await readCurriculumPage(file, { + previousNext: false, + forIndex: true, + }) + ).meta + ) + ); + return modules; +}); + +export function fileToSlug(file: string) { + return file + .replace(`${CURRICULUM_ROOT}/`, "") + .replace(/(\d+-|\.md$|\/0?-?README)/g, ""); +} + +export async function slugToFile(slug: string) { + const all = await allFiles(); + const re = new RegExp( + path.join( + escapeRegExp(CURRICULUM_ROOT), + "curriculum", + `${slug + .split("/") + .map((x) => String.raw`(\d+-)?${escapeRegExp(x)}`) + .join("/")}.md` + ) + ); + return all.find((x) => { + return re.test(x); + }); +} + +export async function buildCurriculumIndex( + mapper: (x: CurriculumMetaData) => CurriculumIndexEntry = (x) => x +): Promise { + const index: CurriculumMetaData[] = await buildIndex(); + + const curriculumIndex = index.reduce((item, meta) => { + const currentLevel = meta.slug.split("/").length; + const last = item.length ? item[item.length - 1] : null; + const entry = mapper(meta); + if (currentLevel > 2) { + if (last) { + last.children.push(entry); + return item; + } + } + + item.push({ children: [], ...entry }); + return item; + }, []); + + return curriculumIndex; +} + +async function buildCurriculumSidebar(): Promise { + const index = await buildCurriculumIndex(({ url, slug, title }) => { + return { url, slug, title }; + }); + + return index; +} + +// This consumes (as in modifies) index. +function prevNextFromIndex( + i: number, + index: CurriculumMetaData[] | CurriculumIndexEntry[] +): PrevNext { + const prevEntry = i > 0 ? index[i - 1] : undefined; + const nextEntry = i < index.length - 1 ? index[i + 1] : undefined; + + prevEntry && "children" in prevEntry && delete prevEntry.children; + nextEntry && "children" in nextEntry && delete nextEntry.children; + + return { prev: prevEntry, next: nextEntry }; +} + +async function buildPrevNextOverview(slug: string): Promise { + const index = (await buildCurriculumIndex()).filter( + (x) => x?.children?.length + ); + const i = index.findIndex((x) => x.slug === slug); + return prevNextFromIndex(i, index); +} + +async function buildPrevNextModule(slug: string): Promise { + const index = await buildIndex(); + const i = index.findIndex((x) => x.slug === slug); + return prevNextFromIndex(i, index); +} + +function breadcrumbPath( + url: string, + entries: CurriculumIndexEntry[] +): DocParent[] | null { + for (const entry of entries) { + if (entry.url === url) { + return [{ uri: entry.url, title: entry.title }]; + } + if (entry.children?.length) { + const found = breadcrumbPath(url, entry.children); + if (found) { + return [{ uri: entry.url, title: entry.title }, ...found]; + } + } + } + return null; +} + +async function buildParents(url: string): Promise { + const index = await buildCurriculumIndex(({ url, title }) => { + return { url, title }; + }); + const parents = breadcrumbPath(url, index); + if (!parents) { + const { url, title } = index[0]; + if (parents[0]?.uri !== url) { + return [{ uri: url, title }, ...parents]; + } + return parents; + } +} + +async function readCurriculumPage( + file: string, + options?: { + previousNext?: boolean; + forIndex?: boolean; + } +): Promise { + const raw = await fs.readFile(file, "utf-8"); + const { attributes, body: rawBody } = frontmatter(raw); + const filename = file.replace(CURRICULUM_ROOT, "").replace(/^\/?/, ""); + let title = rawBody.match(/^[\w\n]*#+(.*\n)/)[1]?.trim() || ""; + const body = rawBody.replace(/^[\w\n]*#+(.*\n)/, ""); + + const slug = fileToSlug(file); + const url = `/${DEFAULT_LOCALE}/${slug}/`; + + let sidebar: CurriculumIndexEntry[]; + let parents: DocParent[]; + + let modules: CurriculumIndexEntry[]; + let prevNext: PrevNext; + if (!options?.forIndex) { + if (attributes.template === Template.Landing) { + modules = (await buildCurriculumIndex())?.filter( + (x) => x.children?.length + ); + } else if (attributes.template === Template.Overview) { + modules = (await buildCurriculumIndex())?.find( + (x) => x.slug === slug + )?.children; + } + if (attributes.template === Template.Module) { + prevNext = await buildPrevNextModule(slug); + } else if (attributes.template === Template.Overview) { + prevNext = await buildPrevNextOverview(slug); + } + + sidebar = await buildCurriculumSidebar(); + parents = await buildParents(url); + } else { + title = title + .replace(/^\d+\s+/, "") // Strip number prefix. + .replace(/ modules$/, "") // Strip "modules" suffix. + .replace(/Extension \d+:/, ""); // Strip "Extension" prefix. + } + + return { + meta: { + filename, + slug, + url, + title, + sidebar, + modules, + parents, + prevNext, + ...attributes, + }, + body, + }; +} + +export async function findCurriculumPageBySlug( + slug: string +): Promise { + const file = + (await slugToFile(slug)) || (await slugToFile(path.join(slug, "README"))); + let module: ReadCurriculum; + try { + module = await readCurriculumPage(file, { forIndex: false }); + } catch (e) { + console.error(`No file found for ${slug}: ${e}`); + return null; + } + const { body, meta } = module; + + const d: CurriculumBuildData = { + url: meta.url, + rawBody: body, + metadata: { locale: DEFAULT_LOCALE, ...meta }, + isMarkdown: true, + fileInfo: { + path: file, + }, + }; + + const doc = await buildCurriculumPage(d); + return { doc }; +} + +export async function buildCurriculumPage( + document: CurriculumBuildData +): Promise { + const { metadata } = document; + + const doc = { locale: DEFAULT_LOCALE } as Partial; + + const renderUrl = document.url.replace(/\/$/, ""); + const [$] = await kumascript.render(renderUrl, {}, document); + + $("[data-token]").removeAttr("data-token"); + $("[data-flaw-src]").removeAttr("data-flaw-src"); + + doc.title = metadata.title.replace(/^\d+\s+/, ""); + doc.mdn_url = document.url; + doc.locale = metadata.locale; + doc.native = LANGUAGES_RAW[DEFAULT_LOCALE]?.native; + + if ($("math").length > 0) { + doc.hasMathML = true; + } + $("div.hidden").remove(); + syntaxHighlight($, doc); + injectNoTranslate($); + injectLoadingLazyAttributes($); + postProcessCurriculumLinks($, (p: string | undefined) => { + const [head, hash] = p?.split("#") || []; + const slug = fileToSlug( + path.normalize(path.join(path.dirname(document.fileInfo.path), head)) + ).replace(/\/$/, ""); + return `/${DEFAULT_LOCALE}/${slug}/${hash ? `#${hash}` : ""}`; + }); + postProcessExternalLinks($); + postLocalFileLinks($, doc); + postProcessSmallerHeadingIDs($); + wrapTables($); + setCurriculumTypes($); + try { + const [sections] = await extractSections($); + doc.body = sections; + } catch (error) { + console.error( + `Extracting sections failed in ${doc.mdn_url} (${document.fileInfo.path})` + ); + throw error; + } + + doc.pageTitle = doc.title + ? `${doc.title} | ${CURRICULUM_TITLE}` + : CURRICULUM_TITLE; + + doc.noIndexing = false; + doc.toc = makeTOC(doc, true).map(({ text, id }) => { + return { text: text.replace(/^[\d.]+\s+/, ""), id }; + }); + doc.sidebar = metadata.sidebar; + doc.modules = metadata.modules; + doc.prevNext = metadata.prevNext; + doc.parents = metadata.parents; + doc.topic = metadata.topic; + + return doc as CurriculumDoc; +} + +export async function buildCurriculum(options: { + verbose?: boolean; + noIndexing?: boolean; +}) { + const locale = DEFAULT_LOCALE; + + for (const file of await allFiles()) { + const { meta, body } = await readCurriculumPage(file, { + forIndex: false, + }); + + const renderDoc: CurriculumBuildData = { + url: meta.url, + rawBody: body, + metadata: { locale, ...meta }, + isMarkdown: true, + fileInfo: { + path: file, + }, + }; + const builtDoc = await buildCurriculumPage(renderDoc); + const { doc } = { + doc: { ...builtDoc, summary: meta.summary, mdn_url: meta.url }, + }; + + const context: HydrationData = { + doc, + pageTitle: meta.title, + locale, + noIndexing: options.noIndexing, + }; + + const outPath = path.join( + BUILD_OUT_ROOT, + locale.toLowerCase(), + slugToFolder(meta.slug) + ); + + await fs.mkdir(outPath, { recursive: true }); + + const html: string = renderHTML(`/${locale}/${meta.slug}/`, context); + + const filePath = path.join(outPath, "index.html"); + const jsonFilePath = path.join(outPath, "index.json"); + + await fs.mkdir(outPath, { recursive: true }); + await fs.writeFile(filePath, html); + await fs.writeFile(jsonFilePath, JSON.stringify(context)); + + if (options.verbose) { + console.log("Wrote", filePath); + } + } +} + +function setCurriculumTypes($: CheerioAPI) { + $("p").each((_, child) => { + const p = $(child); + const text = p.text(); + switch (text) { + case "Learning outcomes:": + p.addClass("curriculum-outcomes"); + break; + case "General resources:": + case "Resources:": + p.addClass("curriculum-resources"); + break; + } + }); + + $("p.curriculum-resources + ul > li").each((_, child) => { + const li = $(child); + + if (li.find("a.external").length) { + li.addClass("external"); + } + }); + + $("blockquote").each((_, child) => { + const bq = $(child); + + const [p] = bq.find("p"); + + if (p) { + const notes = $(p); + if (/((general )?notes?):/i.test(notes.text())) { + bq.addClass("curriculum-notes"); + } + } + }); +} diff --git a/build/extract-sections.ts b/build/extract-sections.ts index f117eb92bbbd..10bfb47b0309 100644 --- a/build/extract-sections.ts +++ b/build/extract-sections.ts @@ -378,7 +378,6 @@ function _addSectionProse( ): SectionsAndFlaws { let id: string | null = null; let title: string | null = null; - let titleAsText = ""; let isH3 = false; const flaws: string[] = []; @@ -402,7 +401,6 @@ function _addSectionProse( // First element id = h2.attr("id") ?? ""; title = h2.html() ?? ""; - titleAsText = h2.text(); h2.remove(); } h2found = true; @@ -423,7 +421,6 @@ function _addSectionProse( } else { id = h3.attr("id") ?? ""; title = h3.html() ?? ""; - titleAsText = h3.text(); if (id && title) { isH3 = true; h3.remove(); @@ -444,12 +441,6 @@ function _addSectionProse( content: $.html()?.trim(), }; - // Only include it if it's useful. It's an optional property and it's - // potentially a waste of space to include it if it's not different. - if (titleAsText && titleAsText !== title) { - value["titleAsText"] = titleAsText; - } - const sections: ProseSection[] = []; if (value.content || value.title) { sections.push({ diff --git a/build/utils.ts b/build/utils.ts index 86750a18e201..e4b34b7517e9 100644 --- a/build/utils.ts +++ b/build/utils.ts @@ -24,6 +24,10 @@ import { BLOG_ROOT } from "../libs/env/index.js"; const { default: imageminPngquant } = imageminPngquantPkg; +export function escapeRegExp(str: string) { + return str.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); +} + export function humanFileSize(size: number) { if (size < 1024) return `${size} B`; const i = Math.floor(Math.log(size) / Math.log(1024)); @@ -239,6 +243,40 @@ export function postProcessExternalLinks($) { }); } +/** + * For every `` remove the ".md" + */ +export function postProcessCurriculumLinks( + $: cheerio.CheerioAPI, + toUrl: (x?: string) => string +) { + // expand relative links + $("a[href^=.]").each((_, element) => { + const $a = $(element); + $a.attr("href", toUrl($a.attr("href"))); + }); + + // remove trailing .md for /en-US/curriculum/* + $("a[href^=/en-US/curriculum]").each((_, element) => { + const $a = $(element); + $a.attr("href", $a.attr("href")?.replace(/(.*)\.md(#.*|$)/, "$1/$2")); + }); + + // remove trailing .md and add locale for /curriculum/* + $("a[href^=/curriculum]").each((_, element) => { + const $a = $(element); + $a.attr("href", $a.attr("href")?.replace(/(.*)\.md(#.*|$)/, "/en-US$1/$2")); + }); + + // remove leading numbers for /en-US/curriculum/* + // /en-US/curriculum/2-core/ -> /en-US/curriculum/core/ + $("a[href^=/en-US/curriculum]").each((_, element) => { + const $a = $(element); + const [head, hash] = $a.attr("href")?.split("#") || []; + $a.attr("href", `${head.replace(/\d+-/g, "")}${hash ? `#${hash}` : ""}`); + }); +} + /** * For every ``, where 'THING' is not a http or / link, make it * `` @@ -294,7 +332,7 @@ export function postProcessSmallerHeadingIDs($) { * * @param {Document} doc */ -export function makeTOC(doc) { +export function makeTOC(doc, withH3 = false) { return doc.body .map((section) => { if ( @@ -303,7 +341,7 @@ export function makeTOC(doc) { section.type === "specifications") && section.value.id && section.value.title && - !section.value.isH3 + (!section.value.isH3 || withH3) ) { return { text: section.value.title, id: section.value.id }; } diff --git a/client/public/assets/curriculum/curriculum-landing-stairway-1.svg b/client/public/assets/curriculum/curriculum-landing-stairway-1.svg new file mode 100644 index 000000000000..dbf043da66b0 --- /dev/null +++ b/client/public/assets/curriculum/curriculum-landing-stairway-1.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/assets/curriculum/curriculum-landing-stairway-2-small.svg b/client/public/assets/curriculum/curriculum-landing-stairway-2-small.svg new file mode 100644 index 000000000000..ce93cff3a58e --- /dev/null +++ b/client/public/assets/curriculum/curriculum-landing-stairway-2-small.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/assets/curriculum/curriculum-landing-stairway-2.svg b/client/public/assets/curriculum/curriculum-landing-stairway-2.svg new file mode 100644 index 000000000000..f5d6c57aceaf --- /dev/null +++ b/client/public/assets/curriculum/curriculum-landing-stairway-2.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/assets/curriculum/curriculum-landing-top.svg b/client/public/assets/curriculum/curriculum-landing-top.svg new file mode 100644 index 000000000000..d4c94059be9c --- /dev/null +++ b/client/public/assets/curriculum/curriculum-landing-top.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/assets/curriculum/curriculum-topic-practices.svg b/client/public/assets/curriculum/curriculum-topic-practices.svg new file mode 100644 index 000000000000..a25e0dbe0f8f --- /dev/null +++ b/client/public/assets/curriculum/curriculum-topic-practices.svg @@ -0,0 +1,5 @@ + + + + diff --git a/client/public/assets/curriculum/curriculum-topic-scripting.svg b/client/public/assets/curriculum/curriculum-topic-scripting.svg new file mode 100644 index 000000000000..706d7dca179b --- /dev/null +++ b/client/public/assets/curriculum/curriculum-topic-scripting.svg @@ -0,0 +1,5 @@ + + + + diff --git a/client/public/assets/curriculum/curriculum-topic-standards.svg b/client/public/assets/curriculum/curriculum-topic-standards.svg new file mode 100644 index 000000000000..67d8810a284b --- /dev/null +++ b/client/public/assets/curriculum/curriculum-topic-standards.svg @@ -0,0 +1,5 @@ + + + + diff --git a/client/public/assets/curriculum/curriculum-topic-styling.svg b/client/public/assets/curriculum/curriculum-topic-styling.svg new file mode 100644 index 000000000000..3782a9fcd4e2 --- /dev/null +++ b/client/public/assets/curriculum/curriculum-topic-styling.svg @@ -0,0 +1,5 @@ + + + + diff --git a/client/public/assets/curriculum/curriculum-topic-tooling.svg b/client/public/assets/curriculum/curriculum-topic-tooling.svg new file mode 100644 index 000000000000..9639c697ed43 --- /dev/null +++ b/client/public/assets/curriculum/curriculum-topic-tooling.svg @@ -0,0 +1,5 @@ + + + + diff --git a/client/src/app.tsx b/client/src/app.tsx index 243208bed85e..4dc6121f0d9c 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -28,6 +28,7 @@ import { HydrationData } from "../../libs/types/hydration"; import { TopPlacement } from "./ui/organisms/placement"; import { Blog } from "./blog"; import { Newsletter } from "./newsletter"; +import { Curriculum } from "./curriculum"; const AllFlaws = React.lazy(() => import("./flaws")); const Translations = React.lazy(() => import("./translations")); @@ -54,7 +55,7 @@ function Layout({ pageType, children }) { } ${pageType}`} > - {pageType !== "document-page" && ( + {pageType !== "document-page" && pageType !== "curriculum" && (
@@ -164,6 +165,14 @@ export function App(appProps: HydrationData) { time it hits any React code. */} + + + + } + /> + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-about-detail.svg b/client/src/assets/icons/curriculum-about-detail.svg new file mode 100644 index 000000000000..bec56ae1fc54 --- /dev/null +++ b/client/src/assets/icons/curriculum-about-detail.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-about-educators.svg b/client/src/assets/icons/curriculum-about-educators.svg new file mode 100644 index 000000000000..1559e79b2bf3 --- /dev/null +++ b/client/src/assets/icons/curriculum-about-educators.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-about-not.svg b/client/src/assets/icons/curriculum-about-not.svg new file mode 100644 index 000000000000..3521556201fe --- /dev/null +++ b/client/src/assets/icons/curriculum-about-not.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-about-students.svg b/client/src/assets/icons/curriculum-about-students.svg new file mode 100644 index 000000000000..7548b7417da1 --- /dev/null +++ b/client/src/assets/icons/curriculum-about-students.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-bullet.svg b/client/src/assets/icons/curriculum-bullet.svg new file mode 100644 index 000000000000..808ff1afb3d9 --- /dev/null +++ b/client/src/assets/icons/curriculum-bullet.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-ext-resource.svg b/client/src/assets/icons/curriculum-ext-resource.svg new file mode 100644 index 000000000000..9ceac6e87cea --- /dev/null +++ b/client/src/assets/icons/curriculum-ext-resource.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-landing-about-beginner.svg b/client/src/assets/icons/curriculum-landing-about-beginner.svg new file mode 100644 index 000000000000..ec9fc6b8848d --- /dev/null +++ b/client/src/assets/icons/curriculum-landing-about-beginner.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-landing-about-bullet.svg b/client/src/assets/icons/curriculum-landing-about-bullet.svg new file mode 100644 index 000000000000..ca5768e931a5 --- /dev/null +++ b/client/src/assets/icons/curriculum-landing-about-bullet.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-landing-about-free.svg b/client/src/assets/icons/curriculum-landing-about-free.svg new file mode 100644 index 000000000000..2f3d5e5bb9f3 --- /dev/null +++ b/client/src/assets/icons/curriculum-landing-about-free.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-landing-about-pace.svg b/client/src/assets/icons/curriculum-landing-about-pace.svg new file mode 100644 index 000000000000..8fa9ba891349 --- /dev/null +++ b/client/src/assets/icons/curriculum-landing-about-pace.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-landing-started-advanced.svg b/client/src/assets/icons/curriculum-landing-started-advanced.svg new file mode 100644 index 000000000000..3797232321ea --- /dev/null +++ b/client/src/assets/icons/curriculum-landing-started-advanced.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-landing-started-beginner.svg b/client/src/assets/icons/curriculum-landing-started-beginner.svg new file mode 100644 index 000000000000..158aabe1761d --- /dev/null +++ b/client/src/assets/icons/curriculum-landing-started-beginner.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-landing-started-educator.svg b/client/src/assets/icons/curriculum-landing-started-educator.svg new file mode 100644 index 000000000000..a76acc3f0b97 --- /dev/null +++ b/client/src/assets/icons/curriculum-landing-started-educator.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-landing-started-employment.svg b/client/src/assets/icons/curriculum-landing-started-employment.svg new file mode 100644 index 000000000000..9750529fb3d4 --- /dev/null +++ b/client/src/assets/icons/curriculum-landing-started-employment.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-mdn-resource.svg b/client/src/assets/icons/curriculum-mdn-resource.svg new file mode 100644 index 000000000000..42acdf738354 --- /dev/null +++ b/client/src/assets/icons/curriculum-mdn-resource.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-modules-underline.svg b/client/src/assets/icons/curriculum-modules-underline.svg new file mode 100644 index 000000000000..ed7f57da69b9 --- /dev/null +++ b/client/src/assets/icons/curriculum-modules-underline.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-next.svg b/client/src/assets/icons/curriculum-next.svg new file mode 100644 index 000000000000..0768c4ccccbd --- /dev/null +++ b/client/src/assets/icons/curriculum-next.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/icons/curriculum-prev.svg b/client/src/assets/icons/curriculum-prev.svg new file mode 100644 index 000000000000..593c2afc9e90 --- /dev/null +++ b/client/src/assets/icons/curriculum-prev.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/icons/curriculum-resources.svg b/client/src/assets/icons/curriculum-resources.svg new file mode 100644 index 000000000000..67042f7e1189 --- /dev/null +++ b/client/src/assets/icons/curriculum-resources.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/client/src/assets/icons/curriculum-started-underline.svg b/client/src/assets/icons/curriculum-started-underline.svg new file mode 100644 index 000000000000..e1e1be93ce51 --- /dev/null +++ b/client/src/assets/icons/curriculum-started-underline.svg @@ -0,0 +1,5 @@ + + + diff --git a/client/src/curriculum/about.scss b/client/src/curriculum/about.scss new file mode 100644 index 000000000000..10b0568e1d34 --- /dev/null +++ b/client/src/curriculum/about.scss @@ -0,0 +1,44 @@ +.curriculum-content-container.curriculum-about { + .curriculum-content { + // Workaround so last toc item works. + margin-bottom: 5rem; + + h2#motivation + div { + li { + list-style-image: var(--curriculum-bullet); + } + } + + h3 { + align-items: center; + display: flex; + gap: 1.25rem; + + &::before { + display: inline-block; + height: 2.5rem; + width: 2.5rem; + } + + &#students::before { + content: var(--curriculum-about-students); + } + + &#educators::before { + content: var(--curriculum-about-educators); + } + + &#whats_covered::before { + content: var(--curriculum-about-covered); + } + + &#level_of_detail::before { + content: var(--curriculum-about-detail); + } + + &#what_is_not_covered::before { + content: var(--curriculum-about-not); + } + } + } +} diff --git a/client/src/curriculum/about.tsx b/client/src/curriculum/about.tsx new file mode 100644 index 000000000000..e41ffe0a5c21 --- /dev/null +++ b/client/src/curriculum/about.tsx @@ -0,0 +1,26 @@ +import { HydrationData } from "../../../libs/types/hydration"; +import { CurriculumDoc } from "../../../libs/types/curriculum"; +import { topic2css, useCurriculumDoc } from "./utils"; +import { RenderCurriculumBody } from "./body"; +import { CurriculumLayout } from "./layout"; + +import "./index.scss"; +import "./about.scss"; + +export function CurriculumAbout(props: HydrationData) { + const doc = useCurriculumDoc(props); + const [coloredTitle, ...restTitle] = doc?.title?.split(" ") || []; + return ( + +
+

+ {coloredTitle} {restTitle.join(" ")} +

+
+ +
+ ); +} diff --git a/client/src/curriculum/body.tsx b/client/src/curriculum/body.tsx new file mode 100644 index 000000000000..d0ddba3c33e6 --- /dev/null +++ b/client/src/curriculum/body.tsx @@ -0,0 +1,19 @@ +import { CurriculumDoc } from "../../../libs/types/curriculum"; +import { Section } from "../../../libs/types/document"; +import { Prose } from "../document/ingredients/prose"; + +export function RenderCurriculumBody({ + doc, + renderer = () => null, +}: { + doc?: CurriculumDoc; + renderer?: (section: Section, i: number) => null | JSX.Element; +}) { + return doc?.body.map((section, i) => { + return ( + renderer(section, i) || ( + + ) + ); + }); +} diff --git a/client/src/curriculum/index.scss b/client/src/curriculum/index.scss new file mode 100644 index 000000000000..0c33a57132a8 --- /dev/null +++ b/client/src/curriculum/index.scss @@ -0,0 +1,268 @@ +@use "../ui/vars" as *; + +.curriculum { + --background-toc-active: var(--curriculum-bg-color); + --category-color: var(--curriculum-category-color); + + --curriculum-bg-color-topic: var(--curriculum-bg-color); + --curriculum-color-topic: var(--curriculum-color); + + .topic-standards { + --curriculum-bg-color-topic: var(--curriculum-bg-color-topic-standards); + --curriculum-color-topic: var(--curriculum-color-topic-standards); + --curriculum-bg-color-list-item-header: var( + --curriculum-bg-color-list-item-topic-standards + ); + --curriculum-color-list-item-icon: var( + --curriculum-color-list-item-icon-topic-standards + ); + --curriculum-bg-color-list-item-icon: var( + --curriculum-bg-color-list-item-icon-topic-standards + ); + } + + .topic-styling { + --curriculum-bg-color-topic: var(--curriculum-bg-color-topic-styling); + --curriculum-color-topic: var(--curriculum-color-topic-styling); + --curriculum-bg-color-list-item-header: var( + --curriculum-bg-color-list-item-topic-styling + ); + --curriculum-color-list-item-icon: var( + --curriculum-color-list-item-icon-topic-styling + ); + --curriculum-bg-color-list-item-icon: var( + --curriculum-bg-color-list-item-icon-topic-styling + ); + } + + .topic-scripting { + --curriculum-bg-color-topic: var(--curriculum-bg-color-topic-scripting); + --curriculum-color-topic: var(--curriculum-color-topic-scripting); + --curriculum-bg-color-list-item-header: var( + --curriculum-bg-color-list-item-topic-scripting + ); + --curriculum-color-list-item-icon: var( + --curriculum-color-list-item-icon-topic-scripting + ); + --curriculum-bg-color-list-item-icon: var( + --curriculum-bg-color-list-item-icon-topic-scripting + ); + } + + .topic-tooling { + --curriculum-bg-color-topic: var(--curriculum-bg-color-topic-tooling); + --curriculum-color-topic: var(--curriculum-color-topic-tooling); + --curriculum-bg-color-list-item-header: var( + --curriculum-bg-color-list-item-topic-tooling + ); + --curriculum-color-list-item-icon: var( + --curriculum-color-list-item-icon-topic-tooling + ); + --curriculum-bg-color-list-item-icon: var( + --curriculum-bg-color-list-item-icon-topic-tooling + ); + } + + .topic-practices { + --curriculum-bg-color-topic: var(--curriculum-bg-color-topic-practices); + --curriculum-color-topic: var(--curriculum-color-topic-practices); + --curriculum-bg-color-list-item-header: var( + --curriculum-bg-color-list-item-topic-practices + ); + --curriculum-color-list-item-icon: var( + --curriculum-color-list-item-icon-topic-practices + ); + --curriculum-bg-color-list-item-icon: var( + --curriculum-bg-color-list-item-icon-topic-practices + ); + } + + .curriculum-content { + .modules { + input[type="radio"]:not(:checked) ~ ol { + display: none; + } + } + } + + .article-actions-container { + // Duplicated from ../ui/molecules/grids/document-page.scss + display: flex; + } + + .sidebar { + > ol:first-of-type > li:first-of-type { + font-size: var(--type-base-font-size-rem); + font-weight: bold; + } + + li > em { + background-color: var(--background-toc-active); + border-left: 2px solid var(--category-color); + display: inline-block; + font-style: normal; + font-weight: 600; + padding: 0.25rem 0.25rem 0.25rem 0.5rem; + width: 100%; + } + } +} + +.curriculum-content-container { + > .curriculum-content { + h1, + h2, + h3, + h4, + h5, + h6 { + a:link, + a:visited { + color: var(--text-primary); + text-decoration: none; + } + + a:hover, + a:focus { + text-decoration: underline; + } + + a:active { + background-color: transparent; + } + + a[href^="#"] { + &::before { + color: var(--text-inactive); + content: "#"; + display: inline-block; + font-size: 0.7em; + line-height: 1; + margin-left: -0.8em; + text-decoration: none; + visibility: hidden; + width: 0.8em; + } + + &:hover { + &::before { + visibility: visible; + } + } + } + } + } + + &, + .button { + --button-padding: 1rem; + } + + .curriculum-sidebar, + .toc, + .curriculum-content { + padding-bottom: 3rem; + padding-top: 3rem; + } + + .curriculum-content { + a { + color: var(--text-link); + + &:link, + &:visited { + text-decoration: underline; + + &.button { + text-decoration: none; + } + } + + &:hover, + &:focus { + text-decoration: none; + } + + &:visited:not([href^="#"]) { + // Distinguish visited links (excl. anchor links). + color: var(--text-visited); + } + } + } + + &.with-sidebar { + > .sidebar-container { + padding-top: 4rem; + } + + .curriculum-content { + grid-area: main; + } + } + + &.curriculum-overview, + &.curriculum-module { + .curriculum-content { + > header > h1 { + margin-bottom: 2rem; + + > span { + color: var(--curriculum-color); + } + } + + .module-contents { + > h2 { + margin-bottom: 2rem; + margin-top: 4rem; + } + } + + section h2:first-of-type { + margin-top: 2rem; + } + + blockquote.curriculum-notes { + background-color: var(--curriculum-bg-color-note); + border: 0; + border-radius: var(--elem-radius); + margin: 1rem; + padding: 0.5rem 2rem; + + > :last-child { + margin-bottom: 0; + } + } + + p.curriculum-outcomes { + display: flex; + font-weight: var(--font-body-strong-weight); + margin-bottom: 0.5rem; + + &::before { + content: url("../assets/icons/curriculum-resources.svg"); + display: block; + height: 24px; + margin-right: 0.5rem; + width: 24px; + } + } + + ol, + ul { + margin: 1rem 0 2rem; + padding-left: 2rem; + + ol, + ul { + margin: 0; + } + } + + li { + list-style-type: disc; + margin: 0.5rem 0; + } + } + } +} diff --git a/client/src/curriculum/index.tsx b/client/src/curriculum/index.tsx new file mode 100644 index 000000000000..777ad4e05a63 --- /dev/null +++ b/client/src/curriculum/index.tsx @@ -0,0 +1,23 @@ +import { Route, Routes } from "react-router-dom"; + +import { HydrationData } from "../../../libs/types/hydration"; +import { CurriculumModuleOverview } from "./overview"; +import { CurriculumModule } from "./module"; +import { CurriculumAbout } from "./about"; +import { CurriculumLanding } from "./landing"; + +import "./index.scss"; + +export function Curriculum(appProps: HydrationData) { + return ( + + } /> + } /> + } + /> + } /> + + ); +} diff --git a/client/src/curriculum/landing.scss b/client/src/curriculum/landing.scss new file mode 100644 index 000000000000..85ea657bf6dd --- /dev/null +++ b/client/src/curriculum/landing.scss @@ -0,0 +1,553 @@ +@use "../ui/vars" as *; + +.curriculum-content-container.curriculum-landing { + background-color: var(--curriculum-bg-color-landing); + margin: 0; + max-width: 100%; + padding: 0; + width: 100%; + + > article { + > header, + > section { + margin: 0 auto 3rem; + max-width: min(var(--max-width), 74rem); + padding-left: var(--gutter); + padding-right: var(--gutter); + width: 100%; + } + } + + .curriculum-content { + padding-top: 0; + + ol.modules-list { + @media screen and (min-width: $screen-sm) { + grid-template-columns: 1fr 1fr; + } + @media screen and (min-width: $screen-md) { + grid-template-columns: 1fr 1fr 1fr; + } + @media screen and (min-width: $screen-lg) { + grid-template-columns: 1fr 1fr 1fr 1fr; + } + } + } + + header.landing-header { + display: grid; + grid-template-areas: "copy" "svg"; + @media screen and (min-width: $screen-md) { + grid-template-areas: "copy svg"; + grid-template-columns: 30rem auto; + } + + > svg { + align-self: end; + grid-area: svg; + justify-self: end; + margin-bottom: 3rem; + max-width: 28rem; + width: 100%; + z-index: 1; + @media screen and (min-width: $screen-md) { + margin-bottom: 0; + } + + #icons-bg { + fill: var(--curriculum-bg-color-landing-top-icon); + } + + .laptop { + fill: var(--curriculum-color-landing-laptop); + } + } + + > section { + grid-area: copy; + margin-right: auto; + margin-top: 3rem; + max-width: 30rem; + padding-right: 1rem; + @media screen and (min-width: $screen-md) { + margin-bottom: 4rem; + } + + h1 { + color: var(--curriculum-color-topic); + font-size: 2rem; + margin-bottom: 0.5rem; + @media screen and (min-width: $screen-md) { + font-size: 2.5rem; + } + } + + h2 { + font-size: 1.25rem; + margin-bottom: 1.5rem; + margin-top: 0.5rem; + @media screen and (min-width: $screen-md) { + font-size: 2rem; + margin-bottom: 2rem; + } + } + + p { + color: var(--text-secondary); + } + } + } + + .landing-about-container { + background-color: var(--curriculum-bg-large-color); + margin: 0; + margin-top: -6rem; + max-width: 100%; + + .landing-about { + display: grid; + grid-template-areas: + "li" + "h2" + "p1" + "p2" + "p3"; + grid-template-columns: 1fr; + margin: 0 auto 3rem; + max-width: min(var(--max-width), 74rem); + @media screen and (min-width: $screen-md) { + grid-template-areas: + "li li " + "h2 . " + ". p1 " + ". p2" + ". p3"; + grid-template-columns: 1fr 1fr; + } + + h2 { + grid-area: h2; + margin: 1rem 0 0; + text-align: center; + } + + > div { + display: contents; + + ul { + align-items: start; + background-color: var(--curriculum-bg-color-landing-about-ul); + border-radius: var(--elem-radius); + box-shadow: var(--curriculum-shadow-landing-about-ul); + color: var(--text-secondary); + display: grid; + grid-area: li; + grid-template-columns: auto auto auto; + justify-content: center; + margin: auto; + min-height: 5rem; + padding: 1rem; + transform: translateY(-1rem); + width: 100%; + @media screen and (min-width: $screen-sm) { + align-items: center; + gap: 1rem; + } + @media screen and (min-width: $screen-md) { + justify-content: start; + } + @media screen and (min-width: $screen-lg) { + justify-content: center; + } + + > li { + margin: 0 0.5rem; + text-align: center; + @media screen and (min-width: $screen-sm) { + margin: 0 1rem; + width: max-content; + } + @media screen and (min-width: $screen-md) { + align-items: center; + display: inline-flex; + gap: 1rem; + } + + &::before { + display: block; + height: 3rem; + margin: 0 auto; + width: 3rem; + @media screen and (min-width: $screen-md) { + display: initial; + margin: 0; + } + } + + &:nth-child(1)::before { + content: var(--curriculum-landing-about-beginner); + } + + &:nth-child(2)::before { + content: var(--curriculum-landing-about-pace); + } + + &:nth-child(3)::before { + content: var(--curriculum-landing-about-free); + } + } + } + + p { + align-items: center; + color: var(--text-secondary); + display: grid; + grid-template-columns: auto auto; + + &::before { + align-self: start; + display: block; + height: 4rem; + width: 4rem; + } + + &:nth-child(2) { + grid-area: p1; + + &::before { + content: var(--curriculum-landing-about-bullet); + } + } + + &:nth-child(3) { + grid-area: p2; + + &::before { + content: var(--curriculum-landing-about-bullet); + } + } + + &:nth-child(4) { + grid-area: p3; + + a { + color: var(--text-primary); + font-weight: var(--font-body-strong-weight); + margin-left: 4rem; + text-decoration: underline; + + &:hover, + &:active { + text-decoration: none; + } + } + } + } + } + } + } + + .landing-stairway { + background-color: var(--background-secondary); + margin: 0; + max-width: 100%; + + > div { + color: var(--text-secondary); + display: grid; + grid-template-areas: "a" "b"; + grid-template-columns: auto; + grid-template-rows: auto auto; + justify-content: center; + margin: 0 auto 3rem; + max-width: min(var(--max-width), 74rem); + padding: 2rem; + padding-left: var(--gutter); + padding-right: var(--gutter); + width: 100%; + + @media screen and (min-width: $screen-md) { + grid-template-columns: min(100%, 34rem); + } + @media screen and (min-width: $screen-lg) { + grid-template-areas: "a b"; + grid-template-columns: 1fr 1.4fr; + } + + svg { + width: 100%; + } + + > #stairway1-container { + --fs: clamp(1rem, calc(3 * calc(100vw / 100)), 1.75rem); + @media screen and (min-width: $screen-lg) { + --fs: clamp(1rem, calc(2 * calc(100vw / 100)), 1.75rem); + } + grid-area: a; + margin: 0; + position: relative; + transform: translateX(2vw); + width: 100%; + + #stairway1 { + font-size: var(--fs); + left: 32%; + position: absolute; + top: 18%; + + > span { + display: block; + line-height: calc(1.25 * var(--fs)); + text-wrap: nowrap; + width: max-content; + } + + .color { + color: var(--curriculum-color); + } + } + } + + > #stairway2-container { + --fs: clamp(0.75rem, calc(1.25 * calc(100vw / 100)), 1rem); + @media screen and (min-width: $screen-md) { + --fs: clamp(0.75rem, calc(100vw / 100), 0.825rem); + } + grid-area: b; + position: relative; + transform: translateX(-5vw); + + #stairway2 { + font-size: var(--fs); + height: 100%; + left: 0; + margin: 0; + position: absolute; + top: 0; + width: calc(100% + 5vw); + + > span { + display: block; + line-height: calc(1.25 * var(--fs)); + max-width: 10rem; + position: absolute; + text-wrap: wrap; + @media screen and (min-width: $screen-md) { + max-width: initial; + text-wrap: nowrap; + } + } + + #stair-1 { + left: 47%; + top: 36%; + } + + #stair-2 { + left: 33%; + top: 52%; + } + + #stair-3 { + left: 23%; + top: 71%; + } + + #stair-4 { + left: 8%; + top: 86%; + } + @media screen and (min-width: $screen-md) { + #stair-1 { + left: 35%; + top: 53%; + } + + #stair-2 { + left: 27%; + top: 65%; + } + + #stair-3 { + left: 13%; + top: 77%; + } + + #stair-4 { + left: 0%; + top: 90%; + } + } + } + + > svg { + &#stairway2large { + display: none; + grid-area: b; + @media screen and (min-width: $screen-md) { + display: initial; + } + } + + &#stairway2small { + grid-area: b; + max-width: 100%; + @media screen and (min-width: $screen-md) { + display: none; + } + } + } + } + } + } + + #dont_know_where_toget_started { + line-height: 3rem; + margin: 1rem auto 5rem; + text-align: center; + width: fit-content; + + &::after { + content: url("../assets/icons/curriculum-started-underline.svg"); + position: absolute; + transform: translate3d(-6em, 1.25rem, 0); + width: 6em; + } + + ~ div { + > ul { + display: grid; + gap: 1rem; + grid-template-areas: "beginner advanced employ educator"; + margin: 0 auto; + max-width: 52rem; + overflow: scroll; + scroll-snap-type: inline mandatory; + @media screen and (min-width: $screen-md) { + gap: 5rem 4rem; + grid-template-areas: + "beginner advanced" + "employ educator"; + grid-template-columns: auto auto; + } + @media screen and (min-width: $screen-lg) { + gap: 5rem 8rem; + } + + > li { + align-items: center; + background-color: var(--curriculum-bg-large-color); + border-radius: var(--elem-radius); + color: var(--text-secondary); + display: grid; + gap: 1rem; + grid-template-areas: + "i h" + "p p" + "c c"; + grid-template-columns: 3rem 1fr; + grid-template-rows: 4rem minmax(7rem, max-content) max-content; + height: max-content; + padding: 1rem 0.5rem; + scroll-snap-align: center; + width: 80vw; + + @media screen and (min-width: $screen-md) { + align-items: start; + background-color: initial; + gap: 1rem 2rem; + grid-template-areas: + "i h" + "i p" + "i c"; + grid-template-columns: auto auto; + grid-template-rows: 4rem auto max-content; + height: initial; + padding: 0; + width: initial; + } + + &::before { + align-self: start; + display: inline-block; + height: 3rem; + width: 3rem; + @media screen and (min-width: $screen-md) { + grid-area: i; + height: 5rem; + width: 5rem; + } + } + + &:nth-child(1)::before { + content: var(--curriculum-landing-started-beginner); + } + + &:nth-child(2)::before { + content: var(--curriculum-landing-started-advanced); + } + + &:nth-child(3)::before { + content: var(--curriculum-landing-started-employment); + } + + &:nth-child(4)::before { + content: var(--curriculum-landing-started-educator); + } + + h3 { + color: var(--text-primary); + font-weight: var(--font-body-strong-weight); + margin-top: 0; + } + + em { + align-self: start; + grid-area: p; + } + + a { + // Mimic the button in content as we only have an
+ --button-bg: var(--button-primary-default); + --button-bg-hover: var(--button-primary-hover); + --button-bg-active: var(--button-primary-active); + --button-border-color: var(--button-primary-default); + --button-focus-effect: var(--focus-effect); + --button-height: var(--form-elem-height, 2rem); + --button-color: var(--background-primary); + --button-font: var(--type-emphasis-m); + --button-radius: var(--elem-radius, 0.25rem); + align-items: center; + background-color: var(--button-bg); + border: 1px solid var(--button-border-color); + border-radius: var(--button-radius); + color: var(--button-color); + cursor: pointer; + display: flex; + font: var(--button-font); + gap: 0.25rem; + + grid-area: c; + justify-content: center; + justify-self: center; + min-height: var(--button-height); + padding: 0.5rem; + padding-left: var(--button-padding); + padding-right: var(--button-padding); + position: relative; + text-align: center; + text-decoration: none; + width: fit-content; + @media screen and (min-width: $screen-md) { + justify-self: start; + } + + &:hover { + background-color: var(--button-bg-hover, var(--button-bg)); + } + } + } + } + } + } +} diff --git a/client/src/curriculum/landing.tsx b/client/src/curriculum/landing.tsx new file mode 100644 index 000000000000..2a4c8ef38718 --- /dev/null +++ b/client/src/curriculum/landing.tsx @@ -0,0 +1,133 @@ +import { ReactComponent as LandingSVG } from "../../public/assets/curriculum/curriculum-landing-top.svg"; +import { ReactComponent as LandingStairwaySVG1 } from "../../public/assets/curriculum/curriculum-landing-stairway-1.svg"; +import { ReactComponent as LandingStairwaySVG2 } from "../../public/assets/curriculum/curriculum-landing-stairway-2.svg"; +import { ReactComponent as LandingStairwaySVG2Small } from "../../public/assets/curriculum/curriculum-landing-stairway-2-small.svg"; +import { HydrationData } from "../../../libs/types/hydration"; +import { CurriculumDoc, CurriculumData } from "../../../libs/types/curriculum"; +import { ModulesListList } from "./modules-list"; +import { useCurriculumDoc } from "./utils"; +import { RenderCurriculumBody } from "./body"; +import { useMemo } from "react"; +import { DisplayH2 } from "../document/ingredients/utils"; +import { CurriculumLayout } from "./layout"; + +import "./index.scss"; +import "./landing.scss"; +import { ProseSection } from "../../../libs/types/document"; + +export function CurriculumLanding(appProps: HydrationData) { + const doc = useCurriculumDoc(appProps as CurriculumData); + return ( + + { + if (i === 0) { + return ( +
+ ); + } + if (section.value.id === "about_the_curriculum") { + return About({ section }); + } + if (section.value.id === "modules") { + const { title, id } = (section as ProseSection).value; + return ( + <> +
+ {title && } + {doc?.modules && } +
+
+
+
+ +

+ How can you + + boost your employability{" "} + + with the MDN + Curriculum? +

+
+
+ + +

+ + Learn about research collaboration and other essential + soft skills. + + + Balance between modern tooling and long-term best + practices. + + + Get access to hight-quality recommended resources. + + + Get guidance from trusted voices. + +

+
+
+
+ + ); + } + return null; + }} + /> + + ); +} + +function Header({ section, h1 }: { section: any; h1?: string }) { + const html = useMemo( + () => ({ __html: section.value?.content }), + [section.value?.content] + ); + return ( +
+
+

{h1}

+

{section.value.title}

+
+
+ +
+ ); +} + +function About({ section }) { + const { title, content, id } = section.value; + const html = useMemo(() => ({ __html: content }), [content]); + return ( +
+
+ +
+
+
+ ); +} diff --git a/client/src/curriculum/layout.tsx b/client/src/curriculum/layout.tsx new file mode 100644 index 000000000000..0043585ef0a3 --- /dev/null +++ b/client/src/curriculum/layout.tsx @@ -0,0 +1,76 @@ +import { ArticleActionsContainer } from "../ui/organisms/article-actions-container"; +import { TopNavigation } from "../ui/organisms/top-navigation"; +import { CurriculumDoc } from "../../../libs/types/curriculum"; +import { PLACEMENT_ENABLED } from "../env"; +import { useDocTitle } from "./utils"; +import { SidebarContainer } from "../document/organisms/sidebar"; +import { Sidebar } from "./sidebar"; +import { TOC } from "../document/organisms/toc"; +import { SidePlacement } from "../ui/organisms/placement"; +import { ReactNode } from "react"; + +import "./index.scss"; + +export function CurriculumLayout({ + doc, + withSidebar = true, + extraClasses = [], + children, +}: { + doc?: CurriculumDoc; + withSidebar?: boolean; + extraClasses?: string[]; + children: ReactNode; +}) { + useDocTitle(doc); + return ( + doc && ( + <> +
+ + +
+
+ {withSidebar && doc.sidebar && ( +
+ + + +
+ + {PLACEMENT_ENABLED && } +
+
+ )} +
+ {children} +
+
+ + ) + ); +} diff --git a/client/src/curriculum/module.scss b/client/src/curriculum/module.scss new file mode 100644 index 000000000000..59aedd7a3a5e --- /dev/null +++ b/client/src/curriculum/module.scss @@ -0,0 +1,55 @@ +@use "../ui/vars" as *; + +.curriculum-content-container.curriculum-module { + .curriculum-content { + > header { + column-gap: 1.5rem; + display: grid; + grid-template-areas: "icon heading" "icon category"; + justify-content: flex-start; + + .topic-icon { + --background-primary: var(--curriculum-bg-color-topic); + align-self: flex-start; + grid-area: icon; + height: 4rem; + width: 4rem; + + + h1 { + grid-area: heading; + margin-bottom: 0; + } + } + + p { + color: var(--curriculum-color-topic); + font-size: var(--type-smaller-font-size); + grid-area: category; + margin: 0; + margin-top: 0.5rem; + + &::before { + content: "Category: "; + } + } + } + + p.curriculum-resources { + margin-bottom: 0.5rem; + + + ul { + padding-left: 2rem; + + > li { + &:not(.external) { + list-style-image: var(--curriculum-module-mdn-resource); + } + + &.external { + list-style-image: url("../assets/icons/curriculum-ext-resource.svg"); + } + } + } + } + } +} diff --git a/client/src/curriculum/module.tsx b/client/src/curriculum/module.tsx new file mode 100644 index 000000000000..81fd8898e03a --- /dev/null +++ b/client/src/curriculum/module.tsx @@ -0,0 +1,28 @@ +import { HydrationData } from "../../../libs/types/hydration"; +import { CurriculumDoc, CurriculumData } from "../../../libs/types/curriculum"; +import { TopicIcon } from "./topic-icon"; +import { PrevNext } from "./prev-next"; +import { RenderCurriculumBody } from "./body"; +import { CurriculumLayout } from "./layout"; +import { topic2css, useCurriculumDoc } from "./utils"; + +import "./index.scss"; +import "./module.scss"; + +export function CurriculumModule(props: HydrationData) { + const doc = useCurriculumDoc(props as CurriculumData); + return ( + +
+ {doc?.topic && } +

{doc?.title}

+ {doc?.topic &&

{doc?.topic}

} +
+ + +
+ ); +} diff --git a/client/src/curriculum/modules-list.scss b/client/src/curriculum/modules-list.scss new file mode 100644 index 000000000000..6083d6dc895e --- /dev/null +++ b/client/src/curriculum/modules-list.scss @@ -0,0 +1,239 @@ +@use "../ui/vars" as *; + +// .with sidebar is needed to +.curriculum-content-container.curriculum-overview, +.curriculum-content-container { + .curriculum-content { + .modules { + input[type="radio"]:not(:checked) ~ a.lets-begin, + input[type="radio"]:not(:checked) ~ ol.modules-list { + display: none; + } + } + + ol.modules-list-list { + display: grid; + grid-template-areas: + "started core extensions" + "hr hr hr" + "mod mod mod" + "cta cta cta"; + + grid-template-columns: auto; + margin: 0; + padding: 0; + + @media screen and (min-width: $screen-sm) { + grid-template-areas: + "started core extensions spacer" + "hr hr hr hr" + "mod mod mod mod" + "cta cta cta cta"; + + grid-template-columns: auto auto auto 1fr; + } + + hr { + border: none; + border-top: 1px solid var(--text-inactive); + grid-area: hr; + margin: 0 0 1.5rem; + width: 100%; + } + + li.modules-list-list-item { + display: contents; + + > input:checked + label { + color: var(--text-primary); + + &::before { + height: 0; + position: absolute; + transform: translate3d(-0.75rem, 0.75rem, 0); + width: 0; + } + } + + > input:checked:focus-visible + label { + outline-color: var(--accent-primary); + outline-offset: 1px; + outline-style: auto; + } + + > label { + color: var(--text-inactive); + cursor: pointer; + } + + &#modules-0 { + > label, + > input { + grid-area: started; + } + + > input:checked + label::before { + content: url("../assets/icons/curriculum-modules-underline.svg#1"); + } + } + + &#modules-1 { + > label, + > input { + grid-area: core; + + @media screen and (min-width: $screen-sm) { + margin-left: 2rem; + } + } + + > input:checked + label::before { + content: url("../assets/icons/curriculum-modules-underline.svg#2"); + } + } + + &#modules-2 { + > label, + > input { + grid-area: extensions; + + @media screen and (min-width: $screen-sm) { + margin-left: 2rem; + } + } + + > input:checked + label::before { + content: url("../assets/icons/curriculum-modules-underline.svg#3"); + } + } + + > ol.modules-list { + grid-area: mod; + margin: 0; + } + + > a.lets-begin { + grid-area: cta; + margin-left: 0.5rem; + margin-top: 2rem; + width: fit-content; + + @media screen and (min-width: $screen-md) { + margin-left: 0; + } + } + } + } + + ol.modules-list { + container-name: module-list; + container-type: inline-size; + display: grid; + flex-wrap: wrap; + gap: 1rem; + grid-template-columns: 1fr 1fr 1fr; + margin: 0; + overflow: scroll; + padding: 0.5rem; + scroll-snap-type: inline mandatory; + + @media screen and (min-width: $screen-md) { + overflow: inherit; + padding: 0; + } + + :focus-visible { + outline-offset: -2px; + } + + li.module-list-item { + background-color: var(--curriculum-bg-color-list-item-body); + border: 1px solid var(--curriculum-border-color-list-item); + border-radius: var(--elem-radius); + box-shadow: var(--curriculum-shadow); + display: flex; + flex-direction: column; + justify-self: center; + max-width: 20rem; + min-width: 15rem; + overflow: auto; + padding: 0; + scroll-snap-align: center; + width: 100%; + + @media screen and (min-width: $screen-md) { + min-width: initial; + } + + &:hover { + border-color: var(--curriculum-border-color-list-item-hover); + } + + > header { + a { + align-items: center; + background-color: var(--curriculum-bg-color-list-item-header); + display: flex; + flex-direction: column; + height: 10rem; + padding: 1rem 2rem; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + svg.topic-icon { + height: 4rem; + width: 4rem; + + circle { + fill: var(--curriculum-bg-color-list-item-icon); + } + + path { + fill: var(--curriculum-color-list-item-icon); + } + } + + > span { + color: var(--text-primary); + font-weight: var(--font-body-strong-weight); + margin: auto; + text-align: center; + } + } + } + + > section { + align-items: center; + display: flex; + flex-direction: column; + font-size: var(--type-smaller-font-size); + height: 11rem; + justify-content: space-between; + padding: 1rem 1.5rem; + + p { + color: var(--text-secondary); + margin: 0; + text-align: center; + } + + p:last-child { + color: var(--curriculum-color-topic); + font-weight: 600; + } + } + } + + @media screen and (min-width: $screen-sm) { + grid-template-columns: 1fr 1fr; + } + + @media screen and (min-width: $screen-xxl) { + grid-template-columns: 1fr 1fr 1fr; + } + } + } +} diff --git a/client/src/curriculum/modules-list.tsx b/client/src/curriculum/modules-list.tsx new file mode 100644 index 000000000000..43b52610e321 --- /dev/null +++ b/client/src/curriculum/modules-list.tsx @@ -0,0 +1,78 @@ +import { CurriculumIndexEntry } from "../../../libs/types/curriculum"; +import { TopicIcon } from "./topic-icon"; +import { topic2css } from "./utils"; + +import "./modules-list.scss"; +import { useState } from "react"; +import { Button } from "../ui/atoms/button"; + +export function ModulesListList({ + modules, +}: { + modules: CurriculumIndexEntry[]; +}) { + const [tab, setTab] = useState(1); + return ( +
    +
    + {modules.map((modulesList, i) => { + return ( +
  1. + setTab(i)} + /> + + {modulesList.children?.length && ( + <> + + + + )} +
  2. + ); + })} +
+ ); +} + +export function ModulesList({ modules }: { modules: CurriculumIndexEntry[] }) { + return ( +
    + {modules.map((module, j) => { + return ( +
  1. +
    + + {module.topic && } + {module.title} + +
    +
    +

    {module.summary}

    +

    {module.topic}

    +
    +
  2. + ); + })} +
+ ); +} diff --git a/client/src/curriculum/overview.tsx b/client/src/curriculum/overview.tsx new file mode 100644 index 000000000000..5e3927902114 --- /dev/null +++ b/client/src/curriculum/overview.tsx @@ -0,0 +1,34 @@ +import { HydrationData } from "../../../libs/types/hydration"; +import { CurriculumDoc, CurriculumData } from "../../../libs/types/curriculum"; +import { ModulesList } from "./modules-list"; +import { topic2css, useCurriculumDoc } from "./utils"; +import { PrevNext } from "./prev-next"; +import { RenderCurriculumBody } from "./body"; +import { CurriculumLayout } from "./layout"; + +import "./index.scss"; + +export function CurriculumModuleOverview( + props: HydrationData +) { + const doc = useCurriculumDoc(props as CurriculumData); + const [coloredTitle, ...restTitle] = doc?.title?.split(" ") || []; + return ( + +
+

+ {coloredTitle} {restTitle.join(" ")} +

+
+ +
+

Module Contents

+ {doc?.modules && } +
+ +
+ ); +} diff --git a/client/src/curriculum/prev-next.scss b/client/src/curriculum/prev-next.scss new file mode 100644 index 000000000000..bcf29c2c01ae --- /dev/null +++ b/client/src/curriculum/prev-next.scss @@ -0,0 +1,12 @@ +.curriculum-prev-next { + display: flex; + flex-wrap: wrap; + gap: 0 1rem; + justify-content: space-between; + margin-top: 2rem; + width: 100%; + + a { + margin: 0.5rem 0; + } +} diff --git a/client/src/curriculum/prev-next.tsx b/client/src/curriculum/prev-next.tsx new file mode 100644 index 000000000000..5cabc7c7594f --- /dev/null +++ b/client/src/curriculum/prev-next.tsx @@ -0,0 +1,32 @@ +import { CurriculumDoc } from "../../../libs/types/curriculum"; +import { Button } from "../ui/atoms/button"; + +import "./prev-next.scss"; + +export function PrevNext({ doc }: { doc?: CurriculumDoc }) { + const { prev, next } = doc?.prevNext || {}; + return ( +
+ {prev && ( + + )} + {next && ( + + )} +
+ ); +} diff --git a/client/src/curriculum/sidebar.tsx b/client/src/curriculum/sidebar.tsx new file mode 100644 index 000000000000..16a4a87ecb17 --- /dev/null +++ b/client/src/curriculum/sidebar.tsx @@ -0,0 +1,88 @@ +import { CurriculumIndexEntry } from "../../../libs/types/curriculum"; + +import "./module.scss"; +export function Sidebar({ + current = "", + extraClasses = "", + sidebar = [], +}: { + current: string; + extraClasses?: string; + sidebar: CurriculumIndexEntry[]; +}) { + return ( + + ); +} + +function SidebarLink({ + current, + url, + title, +}: { + current: string; + url: string; + title: string; +}) { + const isCurrent = url === current; + if (isCurrent) { + return ( + + + {title} + + + ); + } else { + return {title}; + } +} diff --git a/client/src/curriculum/topic-icon.scss b/client/src/curriculum/topic-icon.scss new file mode 100644 index 000000000000..5644b79270ee --- /dev/null +++ b/client/src/curriculum/topic-icon.scss @@ -0,0 +1,9 @@ +svg.topic-icon { + circle { + fill: var(--background-primary); + } + + path { + fill: var(--curriculum-color-topic); + } +} diff --git a/client/src/curriculum/topic-icon.tsx b/client/src/curriculum/topic-icon.tsx new file mode 100644 index 000000000000..d676030ce989 --- /dev/null +++ b/client/src/curriculum/topic-icon.tsx @@ -0,0 +1,30 @@ +import { ReactComponent as ScriptingSVG } from "../../public/assets/curriculum/curriculum-topic-scripting.svg"; +import { ReactComponent as ToolingSVG } from "../../public/assets/curriculum/curriculum-topic-tooling.svg"; +import { ReactComponent as StandardsSVG } from "../../public/assets/curriculum/curriculum-topic-standards.svg"; +import { ReactComponent as StylingSVG } from "../../public/assets/curriculum/curriculum-topic-styling.svg"; +import { ReactComponent as PracticesSVG } from "../../public/assets/curriculum/curriculum-topic-practices.svg"; + +import "./topic-icon.scss"; +import { Topic, topic2css } from "./utils"; + +export function TopicIcon({ topic }: { topic: Topic }) { + const className = `topic-icon ${topic2css(topic)}`; + switch (topic) { + case Topic.WebStandards: + return ; + case Topic.Styling: + return ; + case Topic.Scripting: + return ( + + ); + case Topic.Tooling: + return ; + case Topic.BestPractices: + return ( + + ); + default: + return <>; + } +} diff --git a/client/src/curriculum/utils.ts b/client/src/curriculum/utils.ts new file mode 100644 index 000000000000..054248f3f363 --- /dev/null +++ b/client/src/curriculum/utils.ts @@ -0,0 +1,77 @@ +import { useEffect } from "react"; +import { CurriculumDoc, CurriculumData } from "../../../libs/types/curriculum"; +import useSWR from "swr"; +import { HTTPError } from "../document"; +import { WRITER_MODE } from "../env"; +import { CURRICULUM_TITLE } from "../../../libs/constants"; + +// Using this import fails the build... +// Therefore we're copying until further investigation. +// import { Topic } from "../../../libs/types/curriculum"; +export enum Topic { + WebStandards = "Web Standards & Semantics", + Styling = "Styling", + Scripting = "Scripting", + BestPractices = "Best Practices", + Tooling = "Tooling", + None = "", +} +export function topic2css(topic?: Topic) { + switch (topic) { + case Topic.WebStandards: + return "standards"; + case Topic.Styling: + return "styling"; + case Topic.Scripting: + return "scripting"; + case Topic.Tooling: + return "tooling"; + case Topic.BestPractices: + return "practices"; + default: + return "none"; + } +} + +export function useDocTitle(doc?: CurriculumDoc) { + useEffect(() => { + if (!doc) { + return; + } + document.title = + doc.title && doc.title !== CURRICULUM_TITLE + ? `${doc.title} | ${CURRICULUM_TITLE}` + : CURRICULUM_TITLE; + }, [doc]); +} + +export function useCurriculumDoc( + appProps?: CurriculumData +): CurriculumDoc | undefined { + const dataURL = `./index.json`; + const { data } = useSWR( + dataURL, + async (url) => { + const response = await fetch(url); + + if (!response.ok) { + switch (response.status) { + case 404: + throw new HTTPError(response.status, url, "Page not found"); + } + + const text = await response.text(); + throw new HTTPError(response.status, url, text); + } + + return (await response.json())?.doc; + }, + { + fallbackData: appProps?.doc, + revalidateOnFocus: WRITER_MODE, + revalidateOnMount: !appProps?.doc?.modified, + } + ); + const doc: CurriculumDoc | undefined = data || appProps?.doc || undefined; + return doc; +} diff --git a/client/src/document/hooks.ts b/client/src/document/hooks.ts index 1d6c9e432103..38c9114cea10 100644 --- a/client/src/document/hooks.ts +++ b/client/src/document/hooks.ts @@ -138,6 +138,9 @@ export function useStickyHeaderHeight() { const header = document.getElementsByClassName( "sticky-header-container" )?.[0]; + if (!header) { + return; + } const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { height } = entry.contentRect; diff --git a/client/src/document/ingredients/prose.tsx b/client/src/document/ingredients/prose.tsx index 422134270ef7..631a53dc9f7c 100644 --- a/client/src/document/ingredients/prose.tsx +++ b/client/src/document/ingredients/prose.tsx @@ -13,11 +13,7 @@ export function Prose({ section }: { section: any }) { return (
- +
); diff --git a/client/src/document/ingredients/utils.tsx b/client/src/document/ingredients/utils.tsx index 61984f44a40f..a47a0d05bd28 100644 --- a/client/src/document/ingredients/utils.tsx +++ b/client/src/document/ingredients/utils.tsx @@ -1,44 +1,26 @@ export function DisplayH2({ id, title, - titleAsText, }: { - id: string; + id?: string | null; title: string; - titleAsText?: string; }) { return ( -

- +

+ {id ? : title}

); } -export function DisplayH3({ - id, - title, - titleAsText, -}: { - id: string; - title: string; - titleAsText?: string; -}) { +export function DisplayH3({ id, title }: { id: string; title: string }) { return (

- +

); } -function Permalink({ - id, - title, - titleAsText, -}: { - id: string; - title: string; - titleAsText?: string; -}) { +function Permalink({ id, title }: { id: string; title: string }) { return ( ("sidebar"); @@ -65,12 +67,16 @@ export function SidebarContainer({ aria-label="Collapse sidebar" />