Enhance curriculum catalog functionality: Update CONTRIBUTING.md to reflect changes in curriculum structure, including the introduction of curriculumCoverage for frameworks. Modify frontend components to support a new comparison view for Mathematics, allowing users to compare curricula. Update catalog data to include "Common Core" and improve curriculum filtering logic. Enhance styles for better visual representation of curriculum badges and Mermaid diagrams. Adjust TypeScript types to accommodate new features and ensure backward compatibility with legacy curriculum data.

This commit is contained in:
Hananoshika Yomaru 2026-05-20 14:02:29 -07:00
parent ede9a7aa63
commit 5175811579
34 changed files with 3702 additions and 176 deletions

View file

@ -1,7 +1,9 @@
import { createMemo, createSignal, For, lazy, Show, Suspense } from "solid-js";
import { subjectToMermaid } from "../lib/catalog-to-mermaid";
import { CatalogCompareDoc } from "./catalog-compare-doc";
import { CatalogChapterCard } from "./catalog-chapter-card";
import { CatalogFiltersMenu } from "./catalog-filters-menu";
import { chapterFilterCurriculums } from "../lib/catalog-coverage";
import type {
CatalogChapter,
CatalogData,
@ -14,10 +16,40 @@ const CatalogMermaidView = lazy(() =>
import("./catalog-mermaid-view").then((m) => ({ default: m.CatalogMermaidView }))
);
function normalizeChapter(chapter: CatalogChapter): CatalogChapter {
const coverage = chapter.curriculumCoverage;
if (coverage && typeof coverage === "object" && !Array.isArray(coverage)) {
return chapter;
}
const legacy = chapter.curriculums;
if (!legacy?.length) return { ...chapter, curriculumCoverage: {} };
const curriculumCoverage: CatalogChapter["curriculumCoverage"] = {};
for (const label of legacy) {
curriculumCoverage[label] = "core";
}
return { ...chapter, curriculumCoverage };
}
function normalizeSubject(subject: CatalogSubject): CatalogSubject {
return {
...subject,
strands: (subject.strands ?? []).map((strand) => ({
...strand,
chapters: (strand.chapters ?? []).map(normalizeChapter),
})),
graph: subject.graph ?? { edges: [] },
};
}
function readCatalogData(): CatalogData | null {
const el = document.getElementById("catalog-data");
if (!el?.textContent) return null;
return JSON.parse(el.textContent) as CatalogData;
const raw = JSON.parse(el.textContent) as CatalogData;
return {
...raw,
curriculums: raw.curriculums ?? [],
subjects: (raw.subjects ?? []).map(normalizeSubject),
};
}
function initialSubjectId(subjects: CatalogSubject[]): string {
@ -31,17 +63,21 @@ function initialSubjectId(subjects: CatalogSubject[]): string {
function initialViewMode(): CatalogViewMode {
const params = new URLSearchParams(window.location.search);
return params.get("view") === "tree" ? "tree" : "linear";
const view = params.get("view");
if (view === "tree") return "tree";
if (view === "compare") return "compare";
return "linear";
}
function chapterMatchesFilters(chapter: CatalogChapter, activeFilters: Set<string>): boolean {
if (activeFilters.size === 0) return true;
return chapter.curriculums.some((c) => activeFilters.has(c));
return chapterFilterCurriculums(chapter).some((c) => activeFilters.has(c));
}
function countVisibleChapters(subject: CatalogSubject, activeFilters: Set<string>): number {
return subject.strands.reduce((total, strand) => {
return total + strand.chapters.filter((ch) => chapterMatchesFilters(ch, activeFilters)).length;
return (subject.strands ?? []).reduce((total, strand) => {
const visible = (strand.chapters ?? []).filter((ch) => chapterMatchesFilters(ch, activeFilters));
return total + visible.length;
}, 0);
}
@ -50,6 +86,8 @@ function syncUrl(subjectId: string, viewMode: CatalogViewMode) {
url.searchParams.set("subject", subjectId);
if (viewMode === "tree") {
url.searchParams.set("view", "tree");
} else if (viewMode === "compare") {
url.searchParams.set("view", "compare");
} else {
url.searchParams.delete("view");
}
@ -66,23 +104,36 @@ export function CatalogApp() {
const [activeFilters, setActiveFilters] = createSignal<Set<string>>(new Set());
const [viewMode, setViewMode] = createSignal<CatalogViewMode>(initialViewMode());
const currentSubject = createMemo(
() => data.subjects.find((s) => s.id === currentSubjectId()) ?? data.subjects[0]
);
const currentSubject = createMemo(() => {
const subjects = data.subjects ?? [];
return subjects.find((s) => s.id === currentSubjectId()) ?? subjects[0];
});
const visibleCount = createMemo(() => countVisibleChapters(currentSubject(), activeFilters()));
const visibleCount = createMemo(() => {
const subject = currentSubject();
return subject ? countVisibleChapters(subject, activeFilters()) : 0;
});
const hasChapterTree = createMemo(() => currentSubject().strands.length > 0);
const hasChapterTree = createMemo(() => (currentSubject()?.strands ?? []).length > 0);
const chapterFilter = createMemo(
() => (chapter: CatalogChapter) => chapterMatchesFilters(chapter, activeFilters())
);
const mermaidData = createMemo(() =>
subjectToMermaid(currentSubject(), chapterFilter(), currentSubjectId())
const mermaidData = createMemo(() => {
const subject = currentSubject();
if (!subject) return null;
return subjectToMermaid(subject, chapterFilter(), currentSubjectId());
});
const showCompareDoc = createMemo(
() => currentSubjectId() === "math" && viewMode() === "compare"
);
const statsLabel = createMemo(() => {
if (showCompareDoc()) {
return "Curriculum comparison for Mathematics";
}
const count = visibleCount();
const filters = activeFilters();
const filterText =
@ -91,8 +142,13 @@ export function CatalogApp() {
});
const selectSubject = (subjectId: string) => {
if (subjectId !== "math" && viewMode() === "compare") {
setViewMode("linear");
syncUrl(subjectId, "linear");
} else {
syncUrl(subjectId, viewMode());
}
setCurrentSubjectId(subjectId);
syncUrl(subjectId, viewMode());
};
const setCatalogView = (mode: CatalogViewMode) => {
@ -118,10 +174,13 @@ export function CatalogApp() {
const filteredStrands = createMemo(() => {
const subject = currentSubject();
return subject.strands
if (!subject) return [];
return (subject.strands ?? [])
.map((strand) => ({
...strand,
chapters: strand.chapters.filter((ch) => chapterMatchesFilters(ch, activeFilters())),
chapters: (strand.chapters ?? []).filter((ch) =>
chapterMatchesFilters(ch, activeFilters())
),
}))
.filter((strand) => strand.chapters.length > 0);
});
@ -129,7 +188,10 @@ export function CatalogApp() {
return (
<div
class="catalog-shell"
classList={{ "catalog-shell--tree": viewMode() === "tree" }}
classList={{
"catalog-shell--tree": viewMode() === "tree",
"catalog-shell--compare": showCompareDoc(),
}}
>
<aside class="catalog-sidebar" aria-label="Subjects">
<div class="catalog-sidebar__header">
@ -155,12 +217,12 @@ export function CatalogApp() {
<div class="catalog-main">
<header
class="catalog-header"
classList={{ "catalog-header--has-banner": Boolean(currentSubject().banner) }}
classList={{ "catalog-header--has-banner": Boolean(currentSubject()?.banner) }}
>
<Show when={currentSubject().banner}>
<Show when={currentSubject()?.banner}>
<div class="catalog-header__banner" aria-hidden="true">
<img
src={currentSubject().banner!}
src={currentSubject()!.banner!}
alt=""
width={1400}
height={700}
@ -170,7 +232,7 @@ export function CatalogApp() {
</Show>
<div class="catalog-header__head">
<div class="catalog-header__intro">
<h1 class="catalog-header__title">{currentSubject().name}</h1>
<h1 class="catalog-header__title">{currentSubject()?.name ?? "Catalog"}</h1>
<p class="catalog-header__stats">{statsLabel()}</p>
</div>
<div class="catalog-view-toggle" role="tablist" aria-label="Chapter layout">
@ -192,21 +254,37 @@ export function CatalogApp() {
>
Map
</button>
<Show when={currentSubjectId() === "math"}>
<button
type="button"
class="catalog-view-toggle__btn"
role="tab"
aria-selected={viewMode() === "compare" ? "true" : "false"}
onClick={() => setCatalogView("compare")}
>
Compare
</button>
</Show>
</div>
</div>
<div class="catalog-header__filters">
<CatalogFiltersMenu
curriculums={data.curriculums}
activeFilters={activeFilters}
onToggle={toggleFilter}
/>
</div>
<Show when={!showCompareDoc()}>
<div class="catalog-header__filters">
<CatalogFiltersMenu
curriculums={data.curriculums ?? []}
activeFilters={activeFilters}
onToggle={toggleFilter}
/>
</div>
</Show>
</header>
<div
class="catalog-content"
classList={{ "catalog-content--tree": viewMode() === "tree" }}
>
<Show
when={showCompareDoc()}
fallback={
<Show
when={hasChapterTree() && visibleCount() > 0}
fallback={
@ -262,6 +340,10 @@ export function CatalogApp() {
</Suspense>
</Show>
</Show>
}
>
<CatalogCompareDoc />
</Show>
</div>
</div>
</div>

View file

@ -1,5 +1,6 @@
import { For, Show } from "solid-js";
import type { CatalogChapter } from "../../../data/catalog.types";
import { chapterFilterCurriculums } from "../lib/catalog-coverage";
import { curriculumBadgeClass, tierBadgeLabel } from "../lib/catalog-badge";
export type CatalogChapterCardProps = {
@ -29,7 +30,7 @@ export function CatalogChapterCard(props: CatalogChapterCardProps) {
<p class="catalog-chapter-card__description">{props.chapter.description}</p>
</Show>
<div class="catalog-chapter-card__meta">
<For each={props.chapter.curriculums}>
<For each={chapterFilterCurriculums(props.chapter)}>
{(curriculum) => (
<span class={`catalog-badge ${curriculumBadgeClass(curriculum)}`}>{curriculum}</span>
)}

View file

@ -0,0 +1,24 @@
import { createSignal, onMount, Show } from "solid-js";
function readCompareTemplateHtml(): string {
const el = document.getElementById("catalog-compare-template");
return el?.innerHTML?.trim() ?? "";
}
export function CatalogCompareDoc() {
const [html, setHtml] = createSignal("");
onMount(() => {
setHtml(readCompareTemplateHtml());
});
return (
<Show when={html()}>
<article
class="catalog-compare-doc book-prose"
aria-label="Mathematics curriculum comparison"
innerHTML={html()}
/>
</Show>
);
}

View file

@ -74,7 +74,7 @@ export function CatalogFiltersMenu(props: CatalogFiltersMenuProps) {
/>
<span class="catalog-filter-menu__option-label">All</span>
</label>
<For each={props.curriculums}>
<For each={props.curriculums ?? []}>
{(curriculum) => (
<label class="catalog-filter-menu__option">
<input

View file

@ -1,6 +1,8 @@
import { createEffect, onCleanup, onMount } from "solid-js";
import type { MermaidCatalogDiagram } from "../lib/catalog-to-mermaid";
import { siteCanvasTheme, watchSiteTheme } from "../lib/catalog-canvas-theme";
import { mermaidInitializeOptions } from "../lib/catalog-mermaid-theme";
import { enhanceStrandClusterHeaders } from "../lib/catalog-mermaid-strand-headers";
type CatalogMermaidViewProps = {
diagram: MermaidCatalogDiagram;
@ -84,7 +86,7 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
mermaidModule.initialize({
startOnLoad: false,
securityLevel: "loose",
theme: theme === "dark" ? "dark" : "default",
...mermaidInitializeOptions(theme),
flowchart: {
htmlLabels: true,
curve: "monotoneY",
@ -109,6 +111,8 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
renderedSvg.style.height = "100%";
renderedSvg.style.width = "100%";
enhanceStrandClusterHeaders(renderedSvg, theme);
panZoom = svgPanZoomModule(renderedSvg, {
zoomEnabled: true,
panEnabled: true,

View file

@ -79,7 +79,7 @@ function GraphPrereqDemo() {
}
function CatalogViewsDemo() {
const [view, setView] = createSignal<"list" | "map">("list");
const [view, setView] = createSignal<"list" | "map" | "compare">("list");
return (
<div class="contributing-views-demo" aria-label="Catalog list and map views">
@ -104,9 +104,22 @@ function CatalogViewsDemo() {
>
Map
</button>
<button
type="button"
role="tab"
class="contributing-views-demo__tab"
classList={{ "is-active": view() === "compare" }}
aria-selected={view() === "compare"}
onClick={() => setView("compare")}
>
Compare
</button>
</div>
<div class="contributing-views-demo__panel" role="tabpanel">
<Show
when={view() === "compare"}
fallback={
<Show
when={view() === "list"}
fallback={
<div class="contributing-views-demo__map">
@ -148,6 +161,13 @@ function CatalogViewsDemo() {
List order follows the <code>chapters[]</code> array — your table of contents.
</p>
</Show>
}
>
<p class="contributing-views-demo__caption">
Mathematics only: curricula comparison doc at{" "}
<code>?view=compare</code> (HTML partial, not JSON).
</p>
</Show>
</div>
</div>
);

View file

@ -8,6 +8,7 @@ export function curriculumBadgeClass(curriculum: string): string {
"A-Level": "catalog-badge--a-level",
AP: "catalog-badge--ap",
IGCSE: "catalog-badge--igcse",
"Common Core": "catalog-badge--common-core",
};
return map[curriculum] ?? "catalog-badge--default";
}

View file

@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { chapterFilterCurriculums, coverageRole } from "./catalog-coverage";
import type { CatalogChapter } from "../../../data/catalog.types";
describe("catalog-coverage", () => {
test("reads role from curriculumCoverage", () => {
const chapter: CatalogChapter = {
slug: "quadratic-equations",
title: "Quadratic equations",
description: "",
status: "live",
curriculumCoverage: {
DSE: "core",
IGCSE: "core",
IB: "extension",
},
};
expect(coverageRole(chapter, "DSE")).toBe("core");
expect(coverageRole(chapter, "IB")).toBe("extension");
expect(coverageRole(chapter, "AP")).toBeNull();
expect(chapterFilterCurriculums(chapter)).toEqual(["DSE", "IGCSE", "IB"]);
});
});

View file

@ -0,0 +1,53 @@
import type { CatalogChapter, CatalogChapterTier } from "../../../data/catalog.types";
export type CurriculumCoverageRole = "core" | "extension" | "related";
export type CurriculumCoverage = Partial<Record<string, CurriculumCoverageRole>>;
/** Columns on the Compare chapter matrix (includes frameworks beyond filter chips). */
export const COMPARE_MATRIX_COLUMNS = [
"Foundation",
"DSE",
"IGCSE",
"A-Level",
"AP",
"IB",
"Common Core",
"Singapore",
"UK GCSE",
"Gaokao",
] as const;
export type CompareMatrixColumn = (typeof COMPARE_MATRIX_COLUMNS)[number];
export function coverageRole(
chapter: CatalogChapter,
framework: string
): CurriculumCoverageRole | null {
const coverage = chapter.curriculumCoverage;
if (coverage && framework in coverage) {
return coverage[framework] ?? null;
}
if (chapter.curriculums?.includes(framework)) {
return "core";
}
return null;
}
/** Framework labels used for catalog filter chips (any mapped role counts). */
export function chapterFilterCurriculums(chapter: CatalogChapter | undefined): string[] {
if (!chapter) return [];
const coverage = chapter.curriculumCoverage;
if (coverage && typeof coverage === "object" && !Array.isArray(coverage)) {
return Object.keys(coverage);
}
if (Array.isArray(chapter.curriculums)) {
return chapter.curriculums;
}
return [];
}
/** DSE compulsory vs M2-style extension when tier is set. */
export function defaultDseRole(tier?: CatalogChapterTier): CurriculumCoverageRole {
return tier === "non-foundation" ? "extension" : "core";
}

View file

@ -0,0 +1,9 @@
import { describe, expect, test } from "bun:test";
import { isStrandClusterId } from "./catalog-mermaid-strand-headers";
describe("catalog-mermaid-strand-headers", () => {
test("isStrandClusterId matches strand subgraph ids", () => {
expect(isStrandClusterId("flowchart-strand_number_algebra-1")).toBe(true);
expect(isStrandClusterId("flowchart-root-0")).toBe(false);
});
});

View file

@ -0,0 +1,99 @@
import {
mermaidThemeVariables,
type MermaidThemeMode,
} from "./catalog-mermaid-theme";
const SVG_NS = "http://www.w3.org/2000/svg";
/** Header band height — must fit title line-height + descenders inside foreignObject */
export const STRAND_HEADER_HEIGHT = 44;
const STRAND_HEADER_PAD_X = 16;
function applySvgFill(el: SVGGraphicsElement, fill: string): void {
el.setAttribute("fill", fill);
el.style.fill = fill;
}
export function isStrandClusterId(id: string): boolean {
return id.includes("strand_");
}
export function isStrandCluster(cluster: SVGGElement): boolean {
return isStrandClusterId(cluster.id ?? "");
}
/**
* Mermaid subgraph titles are plain labels on the cluster border. Overlay a header band
* and tuck the title into it (Cursor-style subgraph chrome).
*/
export function enhanceStrandClusterHeaders(
svg: SVGSVGElement,
mode: MermaidThemeMode = "light"
): void {
const colors = mermaidThemeVariables(mode);
const clusters = svg.querySelectorAll<SVGGElement>("g.cluster");
for (const cluster of clusters) {
if (!isStrandCluster(cluster)) continue;
if (cluster.querySelector(".catalog-mermaid-strand-header")) continue;
const frameRect = cluster.querySelector<SVGRectElement>(":scope > rect");
const labelGroup = cluster.querySelector<SVGGElement>(":scope > g.cluster-label");
if (!frameRect || !labelGroup) continue;
const bbox = cluster.getBBox();
if (bbox.width <= 0 || bbox.height <= 0) continue;
cluster.classList.add("catalog-mermaid-strand-cluster");
const header = document.createElementNS(SVG_NS, "rect");
header.setAttribute("class", "catalog-mermaid-strand-header");
header.setAttribute("x", String(bbox.x));
header.setAttribute("y", String(bbox.y));
header.setAttribute("width", String(bbox.width));
header.setAttribute("height", String(STRAND_HEADER_HEIGHT));
applySvgFill(header, colors.secondaryColor);
const rule = document.createElementNS(SVG_NS, "line");
rule.setAttribute("class", "catalog-mermaid-strand-header__rule");
rule.setAttribute("x1", String(bbox.x));
rule.setAttribute("y1", String(bbox.y + STRAND_HEADER_HEIGHT));
rule.setAttribute("x2", String(bbox.x + bbox.width));
rule.setAttribute("y2", String(bbox.y + STRAND_HEADER_HEIGHT));
rule.setAttribute("stroke", colors.clusterBorder);
frameRect.classList.add("catalog-mermaid-strand-frame");
applySvgFill(frameRect, colors.clusterBkg);
frameRect.setAttribute("rx", "0");
frameRect.setAttribute("ry", "0");
frameRect.setAttribute("stroke", colors.clusterBorder);
cluster.insertBefore(header, frameRect.nextSibling);
cluster.insertBefore(rule, header.nextSibling);
labelGroup.setAttribute("transform", `translate(${bbox.x}, ${bbox.y})`);
const foreignObject = labelGroup.querySelector<SVGForeignObjectElement>("foreignObject");
if (foreignObject) {
foreignObject.setAttribute("class", "catalog-mermaid-strand-label-fo");
foreignObject.setAttribute("overflow", "visible");
foreignObject.setAttribute("width", String(Math.max(bbox.width, 120)));
foreignObject.setAttribute("height", String(STRAND_HEADER_HEIGHT));
}
for (const el of labelGroup.querySelectorAll<HTMLElement>("div, span, p, header")) {
el.style.overflow = "visible";
el.style.border = "none";
el.style.borderRadius = "0";
el.style.background = "transparent";
el.style.boxShadow = "none";
}
const labelRoot = labelGroup.querySelector<HTMLElement>("div");
if (labelRoot) {
labelRoot.style.height = `${STRAND_HEADER_HEIGHT}px`;
labelRoot.style.width = "100%";
labelRoot.style.boxSizing = "border-box";
labelRoot.style.padding = `0 ${STRAND_HEADER_PAD_X}px`;
labelRoot.style.display = "flex";
labelRoot.style.alignItems = "center";
}
}
}

View file

@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";
import { mermaidInitializeOptions, mermaidThemeVariables } from "./catalog-mermaid-theme";
describe("catalog-mermaid-theme", () => {
test("uses base theme with distinct cluster and secondary colors", () => {
const dark = mermaidThemeVariables("dark");
expect(dark.clusterBkg).not.toBe(dark.secondaryColor);
expect(dark.clusterBkg).toBe("#202020");
expect(dark.secondaryColor).toBe("#3d3a36");
const init = mermaidInitializeOptions("light");
expect(init.theme).toBe("base");
expect(init.themeVariables.clusterBkg).toBe("#f0efec");
expect(init.themeVariables.secondaryColor).toBe("#e3e1dc");
});
});

View file

@ -0,0 +1,69 @@
/**
* Mermaid only applies custom cluster/node colors via theme "base" + themeVariables (hex).
* CSS cannot override Mermaid's inline SVG fills on cluster rects.
*/
export type MermaidThemeMode = "light" | "dark";
export type CatalogMermaidThemeVariables = {
darkMode: boolean;
background: string;
primaryColor: string;
primaryTextColor: string;
primaryBorderColor: string;
secondaryColor: string;
tertiaryColor: string;
clusterBkg: string;
clusterBorder: string;
lineColor: string;
titleColor: string;
mainBkg: string;
nodeBorder: string;
edgeLabelBackground: string;
};
/** Strand title band (secondary) vs subgraph body (clusterBkg / tertiary). */
export function mermaidThemeVariables(mode: MermaidThemeMode): CatalogMermaidThemeVariables {
if (mode === "dark") {
return {
darkMode: true,
background: "#1a1917",
primaryColor: "#2a2826",
primaryTextColor: "#eceae4",
primaryBorderColor: "#4a4844",
secondaryColor: "#3d3a36",
tertiaryColor: "#202020",
clusterBkg: "#202020",
clusterBorder: "#4a4844",
lineColor: "#c4924a",
titleColor: "#eceae4",
mainBkg: "#2a2826",
nodeBorder: "#4a4844",
edgeLabelBackground: "#2a2826",
};
}
return {
darkMode: false,
background: "#f5f4f1",
primaryColor: "#faf9f7",
primaryTextColor: "#1a1a1a",
primaryBorderColor: "#e8e8e8",
secondaryColor: "#e3e1dc",
tertiaryColor: "#f0efec",
clusterBkg: "#f0efec",
clusterBorder: "#e8e8e8",
lineColor: "#9a6b2e",
titleColor: "#1a1a1a",
mainBkg: "#faf9f7",
nodeBorder: "#e8e8e8",
edgeLabelBackground: "#f5f4f1",
};
}
export function mermaidInitializeOptions(mode: MermaidThemeMode) {
return {
theme: "base" as const,
themeVariables: mermaidThemeVariables(mode),
};
}

View file

@ -11,7 +11,7 @@ function chapter(
title: slug,
description: "desc",
status: "planned",
curriculums: ["DSE"],
curriculumCoverage: { DSE: "core" },
...overrides,
};
}
@ -55,6 +55,8 @@ describe("subjectToMermaid", () => {
expect(diagram!.rootNodeId).toBe("chapter_a");
expect(source).toContain("flowchart TB");
expect(source).toContain("catalog-mermaid-strand__title");
expect(source).toContain("Strand A");
expect(source.match(/chapter_a --> chapter_c/g)?.length).toBe(1);
expect(source).toContain('chapter_b -->|after| chapter_c');
expect(source).toContain('click chapter_a href "/math/a/"');

View file

@ -1,4 +1,5 @@
import type { CatalogChapter, CatalogGraphEdge, CatalogSubject } from "../../../data/catalog.types";
import { chapterFilterCurriculums } from "./catalog-coverage";
import { curriculumBadgeClass, tierBadgeLabel } from "./catalog-badge";
export type MermaidCatalogDiagram = {
@ -11,7 +12,7 @@ function collectVisibleChapters(
matches: (chapter: CatalogChapter) => boolean
): Map<string, CatalogChapter> {
const visible = new Map<string, CatalogChapter>();
for (const strand of subject.strands) {
for (const strand of subject.strands ?? []) {
for (const chapter of strand.chapters) {
if (matches(chapter)) {
visible.set(chapter.slug, chapter);
@ -82,7 +83,7 @@ function selectRootSlug(
}
}
for (const strand of subject.strands) {
for (const strand of subject.strands ?? []) {
for (const chapter of strand.chapters) {
if (rootCandidates.has(chapter.slug)) {
return chapter.slug;
@ -98,7 +99,7 @@ function chapterNodeLabel(chapter: CatalogChapter): string {
const description = chapter.description
? `<p class='catalog-mermaid-node__description'>${escapeLabel(chapter.description)}</p>`
: "";
const curriculumBadges = chapter.curriculums
const curriculumBadges = chapterFilterCurriculums(chapter)
.map(
(curriculum) =>
`<span class='catalog-badge ${curriculumBadgeClass(curriculum)}'>${escapeLabel(curriculum)}</span>`
@ -128,11 +129,13 @@ export function subjectToMermaid(
const rootSlug = selectRootSlug(subject, visible, edges);
const lines: string[] = ["flowchart TB"];
for (const strand of subject.strands) {
for (const strand of subject.strands ?? []) {
const strandChapters = strand.chapters.filter((chapter) => visible.has(chapter.slug));
if (strandChapters.length === 0) continue;
lines.push(` subgraph strand_${strand.id.replaceAll(/[^a-zA-Z0-9_]/g, "_")}["${escapeLabel(strand.title)}"]`);
const strandKey = strand.id.replaceAll(/[^a-zA-Z0-9_]/g, "_");
const strandHeader = `<header class='catalog-mermaid-strand__header'><span class='catalog-mermaid-strand__title'>${escapeLabel(strand.title)}</span></header>`;
lines.push(` subgraph strand_${strandKey}["${strandHeader}"]`);
lines.push(" direction TB");
for (const chapter of strandChapters) {
const id = nodeId(chapter.slug);