Enhance contributing guidelines in CONTRIBUTING.md with a readable link, update curriculum JSON structure to include chapter tiers, and improve catalog chapter card display with tier badges. Update package.json for testing scripts and add contributing URL in zola.toml. Refactor frontend components and styles for better integration of new features.

This commit is contained in:
Hananoshika Yomaru 2026-05-19 05:34:25 -07:00
parent 8c986c1ed7
commit 81b316e08d
33 changed files with 6944 additions and 152 deletions

View file

@ -1,6 +1,6 @@
import { For, Show } from "solid-js";
import type { CatalogChapter } from "../../../data/catalog.types";
import { curriculumBadgeClass } from "../lib/catalog-badge";
import { curriculumBadgeClass, tierBadgeLabel } from "../lib/catalog-badge";
export type CatalogChapterCardProps = {
chapter: CatalogChapter;
@ -24,6 +24,14 @@ function appendMeta(parent: HTMLElement, chapter: CatalogChapter) {
meta.appendChild(badge);
}
const tierLabel = tierBadgeLabel(chapter.tier);
if (tierLabel) {
const tier = document.createElement("span");
tier.className = "catalog-badge catalog-badge--extension";
tier.textContent = tierLabel;
meta.appendChild(tier);
}
if (chapter.status === "planned") {
const soon = document.createElement("span");
soon.className = "book-chapter-badge book-chapter-badge--planned";
@ -154,6 +162,9 @@ export function CatalogChapterCard(props: CatalogChapterCardProps) {
<span class={`catalog-badge ${curriculumBadgeClass(curriculum)}`}>{curriculum}</span>
)}
</For>
<Show when={tierBadgeLabel(props.chapter.tier)}>
{(label) => <span class="catalog-badge catalog-badge--extension">{label()}</span>}
</Show>
<Show when={props.chapter.status === "planned"}>
<span class="book-chapter-badge book-chapter-badge--planned">Coming soon</span>
</Show>

View file

@ -0,0 +1,166 @@
import { createSignal, Show } from "solid-js";
import { render } from "solid-js/web";
const GRAPH_EDGES: Array<{ from: string; to: string }> = [
{ from: "quadratic", to: "sequences" },
{ from: "quadratic", to: "functions" },
{ from: "sequences", to: "linear-programming" },
{ from: "functions", to: "linear-programming" },
];
function GraphPrereqDemo() {
const [focus, setFocus] = createSignal<string | null>(null);
const nodeLit = (id: string) => {
const active = focus();
if (!active) return false;
if (id === active) return true;
return GRAPH_EDGES.some(
(edge) =>
(edge.from === active && edge.to === id) || (edge.from === id && edge.to === active)
);
};
const toggle = (id: string) => setFocus((prev) => (prev === id ? null : id));
return (
<div class="contributing-graph-demo" aria-label="Interactive prerequisite graph example">
<p class="contributing-demo__hint">
Click a topic to highlight its prerequisites and follow-on chapters.
</p>
<div class="contributing-graph-demo__layout">
<button
type="button"
class="contributing-graph-demo__node contributing-graph-demo__node--root"
classList={{ "is-lit": nodeLit("quadratic") }}
onClick={() => toggle("quadratic")}
>
Quadratic equations
</button>
<span class="contributing-graph-demo__fork" aria-hidden="true">
↙
<span class="contributing-graph-demo__fork-line" />
↘
</span>
<div class="contributing-graph-demo__row">
<button
type="button"
class="contributing-graph-demo__node"
classList={{ "is-lit": nodeLit("sequences") }}
onClick={() => toggle("sequences")}
>
Sequences
</button>
<button
type="button"
class="contributing-graph-demo__node"
classList={{ "is-lit": nodeLit("functions") }}
onClick={() => toggle("functions")}
>
Functions
</button>
</div>
<span class="contributing-graph-demo__join" aria-hidden="true">
↘
<span class="contributing-graph-demo__join-line" />
↙
</span>
<button
type="button"
class="contributing-graph-demo__node contributing-graph-demo__node--merge"
classList={{ "is-lit": nodeLit("linear-programming") }}
onClick={() => toggle("linear-programming")}
>
Linear programming
</button>
</div>
</div>
);
}
function CatalogViewsDemo() {
const [view, setView] = createSignal<"list" | "map">("list");
return (
<div class="contributing-views-demo" aria-label="Catalog list and map views">
<div class="contributing-views-demo__tabs" role="tablist" aria-label="Preview catalog views">
<button
type="button"
role="tab"
class="contributing-views-demo__tab"
classList={{ "is-active": view() === "list" }}
aria-selected={view() === "list"}
onClick={() => setView("list")}
>
List
</button>
<button
type="button"
role="tab"
class="contributing-views-demo__tab"
classList={{ "is-active": view() === "map" }}
aria-selected={view() === "map"}
onClick={() => setView("map")}
>
Map
</button>
</div>
<div class="contributing-views-demo__panel" role="tabpanel">
<Show
when={view() === "list"}
fallback={
<div class="contributing-views-demo__map">
<div class="contributing-views-demo__map-col">
<span class="contributing-views-demo__strand">Number &amp; Algebra</span>
<span class="contributing-views-demo__map-node is-root">Directed numbers</span>
<span class="contributing-views-demo__map-arrow" aria-hidden="true">
↓
</span>
<span class="contributing-views-demo__map-node">Quadratic equations</span>
<span class="contributing-views-demo__map-fork" aria-hidden="true">
↙ ↘
</span>
<div class="contributing-views-demo__map-row">
<span class="contributing-views-demo__map-node is-small">Sequences</span>
<span class="contributing-views-demo__map-node is-small">Functions</span>
</div>
</div>
<p class="contributing-views-demo__caption">
Arrows come from <code>graph.edges</code> only — strand order in JSON does not draw
links.
</p>
</div>
}
>
<ol class="contributing-views-demo__list">
<li>
<span class="contributing-views-demo__list-num">01</span>
<span class="contributing-views-demo__list-title">Quadratic equations</span>
<span class="contributing-views-demo__list-badge">DSE</span>
</li>
<li>
<span class="contributing-views-demo__list-num">02</span>
<span class="contributing-views-demo__list-title">Sequences &amp; series</span>
<span class="contributing-views-demo__list-badge is-muted">Coming soon</span>
</li>
</ol>
<p class="contributing-views-demo__caption">
List order follows the <code>chapters[]</code> array — your table of contents.
</p>
</Show>
</div>
</div>
);
}
export function mountContributingDemos() {
const graphRoot = document.getElementById("contributing-demo-graph");
if (graphRoot) {
render(() => <GraphPrereqDemo />, graphRoot);
}
const viewsRoot = document.getElementById("contributing-demo-views");
if (viewsRoot) {
render(() => <CatalogViewsDemo />, viewsRoot);
}
}

View file

@ -1,3 +1,5 @@
import type { CatalogChapterTier } from "../../../data/catalog.types";
export function curriculumBadgeClass(curriculum: string): string {
const map: Record<string, string> = {
DSE: "catalog-badge--dse",
@ -8,3 +10,9 @@ export function curriculumBadgeClass(curriculum: string): string {
};
return map[curriculum] ?? "catalog-badge--default";
}
/** Shown on cards when tier is non-foundation; foundation chapters have no extra badge. */
export function tierBadgeLabel(tier?: CatalogChapterTier): string | null {
if (tier === "non-foundation") return "Extension";
return null;
}

View file

@ -17,6 +17,7 @@ export const CANVAS_NODE_DARK = {
} as const;
export function siteCanvasTheme(): "light" | "dark" {
if (typeof document === "undefined") return "light";
const attr = document.documentElement.getAttribute("data-theme");
if (attr === "dark" || attr === "light") return attr;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";

View file

@ -0,0 +1,193 @@
import { describe, expect, test } from "bun:test";
import type { JSONCanvas } from "json-canvas-viewer";
import type {
CatalogChapter,
CatalogGraphEdge,
CatalogSubject,
} from "../../../data/catalog.types";
import {
CHAPTER_NODE_WIDTH,
chapterNodeId,
chapterSlugFromNodeId,
relayoutCanvasChapterHeights,
subjectToCanvas,
} from "./catalog-to-canvas";
const GAP_Y = 48;
function chapter(
slug: string,
overrides: Partial<CatalogChapter> = {}
): CatalogChapter {
return {
slug,
title: slug,
description: "Test chapter description for layout.",
status: "planned",
curriculums: ["DSE"],
...overrides,
};
}
function miniSubject(chapters: CatalogChapter[], edges: CatalogGraphEdge[] = []): CatalogSubject {
return {
id: "math",
name: "Mathematics",
strands: [
{
id: "number-algebra",
title: "Number & Algebra",
chapters,
},
],
graph: { edges },
};
}
function textNode(canvas: JSONCanvas, slug: string) {
return (canvas.nodes ?? []).find((node) => node.id === chapterNodeId(slug) && node.type === "text");
}
function bottomY(node: { y: number; height?: number }) {
return node.y + (node.height ?? 0);
}
describe("chapterNodeId", () => {
test("maps slug to node id and back", () => {
expect(chapterNodeId("quadratic-equations")).toBe("chapter-quadratic-equations");
expect(chapterSlugFromNodeId("chapter-quadratic-equations")).toBe("quadratic-equations");
expect(chapterSlugFromNodeId("group-strand")).toBeNull();
});
});
describe("subjectToCanvas", () => {
test("returns null when no chapters match filter", () => {
const subject = miniSubject([chapter("a")]);
expect(subjectToCanvas(subject, () => false)).toBeNull();
});
test("uses only explicit graph edges (no implicit strand chain)", () => {
const subject = miniSubject(
[chapter("first"), chapter("second")],
[]
);
const canvas = subjectToCanvas(subject, () => true);
expect(canvas?.edges ?? []).toHaveLength(0);
});
test("assigns longest-path levels on y axis", () => {
const subject = miniSubject(
[chapter("a"), chapter("b"), chapter("c")],
[
{ from: "a", to: "b" },
{ from: "b", to: "c" },
]
);
const canvas = subjectToCanvas(subject, () => true)!;
const a = textNode(canvas, "a")!;
const b = textNode(canvas, "b")!;
const c = textNode(canvas, "c")!;
expect(b.y).toBeGreaterThan(bottomY(a));
expect(c.y).toBeGreaterThan(bottomY(b));
});
test("stacks siblings that share a level in one column", () => {
const subject = miniSubject(
[chapter("alpha"), chapter("beta"), chapter("merge")],
[
{ from: "alpha", to: "merge" },
{ from: "beta", to: "merge" },
]
);
const canvas = subjectToCanvas(subject, () => true)!;
const alpha = textNode(canvas, "alpha")!;
const beta = textNode(canvas, "beta")!;
const merge = textNode(canvas, "merge")!;
expect(alpha.x).toBe(beta.x);
expect(beta.y).toBeGreaterThanOrEqual(bottomY(alpha) + GAP_Y);
expect(merge.y).toBeGreaterThan(bottomY(alpha));
expect(merge.y).toBeGreaterThan(bottomY(beta));
});
test("places strand columns at increasing x", () => {
const subject: CatalogSubject = {
id: "math",
name: "Mathematics",
strands: [
{
id: "number-algebra",
title: "Number & Algebra",
chapters: [chapter("na")],
},
{
id: "data-handling",
title: "Data Handling",
chapters: [chapter("dh")],
},
],
graph: { edges: [] },
};
const canvas = subjectToCanvas(subject, () => true)!;
const na = textNode(canvas, "na")!;
const dh = textNode(canvas, "dh")!;
expect(dh.x).toBeGreaterThan(na.x);
expect(dh.x - na.x).toBeGreaterThanOrEqual(CHAPTER_NODE_WIDTH);
});
test("throws when the graph has a cycle", () => {
const subject = miniSubject(
[chapter("a"), chapter("b"), chapter("c")],
[
{ from: "a", to: "b" },
{ from: "b", to: "c" },
{ from: "c", to: "a" },
]
);
expect(() => subjectToCanvas(subject, () => true)).toThrow(/cycle/i);
});
});
describe("relayoutCanvasChapterHeights", () => {
test("handles canvas with undefined nodes and edges", () => {
expect(relayoutCanvasChapterHeights({}, new Map())).toBeNull();
});
test("returns null when layout unchanged", () => {
const subject = miniSubject([chapter("solo")], []);
const canvas = subjectToCanvas(subject, () => true)!;
const node = textNode(canvas, "solo")!;
const heights = new Map([["solo", node.height ?? 0]]);
expect(relayoutCanvasChapterHeights(canvas, heights)).toBeNull();
});
test("bumps y when a taller height is supplied for a sibling stack", () => {
const subject = miniSubject(
[chapter("top"), chapter("bottom")],
[]
);
const canvas = subjectToCanvas(subject, () => true)!;
const top = textNode(canvas, "top")!;
const bottom = textNode(canvas, "bottom")!;
const firstSlug = top.y <= bottom.y ? "top" : "bottom";
const secondSlug = firstSlug === "top" ? "bottom" : "top";
const first = textNode(canvas, firstSlug)!;
const second = textNode(canvas, secondSlug)!;
const taller = (first.height ?? 0) + 80;
const fitted = relayoutCanvasChapterHeights(
canvas,
new Map([
[firstSlug, taller],
[secondSlug, second.height ?? 0],
])
);
expect(fitted).not.toBeNull();
const moved = textNode(fitted!, secondSlug)!;
expect(moved.y).toBeGreaterThanOrEqual(first.y + taller + GAP_Y);
});
});

View file

@ -59,13 +59,6 @@ function buildEdgeList(
add(edge.from, edge.to, edge.label);
}
for (const strand of subject.strands) {
const chapters = strand.chapters.filter((ch) => visible.has(ch.slug));
for (let i = 0; i < chapters.length - 1; i++) {
add(chapters[i].slug, chapters[i + 1].slug);
}
}
return edges;
}
@ -100,64 +93,99 @@ function assertAcyclic(edges: CatalogGraphEdge[], slugs: Iterable<string>): void
}
}
/** Longest-path rank: level(to) >= level(from) + 1 for every required edge; roots at 0. */
function assignLevels(edges: CatalogGraphEdge[], slugs: string[]): Map<string, number> {
const inDegree = new Map<string, number>();
const adj = new Map<string, string[]>();
for (const slug of slugs) {
inDegree.set(slug, 0);
adj.set(slug, []);
}
for (const { from, to } of edges) {
adj.get(from)?.push(to);
inDegree.set(to, (inDegree.get(to) ?? 0) + 1);
}
const levels = new Map<string, number>();
const queue: Array<{ slug: string; level: number }> = [];
for (const slug of slugs) {
if ((inDegree.get(slug) ?? 0) === 0) {
queue.push({ slug, level: 0 });
}
levels.set(slug, 0);
}
if (queue.length === 0 && slugs.length > 0) {
queue.push({ slug: slugs[0], level: 0 });
}
while (queue.length > 0) {
const { slug, level } = queue.shift()!;
levels.set(slug, Math.max(level, levels.get(slug) ?? 0));
for (const next of adj.get(slug) ?? []) {
const nextLevel = level + 1;
levels.set(next, Math.max(nextLevel, levels.get(next) ?? 0));
inDegree.set(next, (inDegree.get(next) ?? 1) - 1);
if ((inDegree.get(next) ?? 0) === 0) {
queue.push({ slug: next, level: nextLevel });
for (let pass = 0; pass < slugs.length; pass++) {
let changed = false;
for (const { from, to } of edges) {
const next = (levels.get(from) ?? 0) + 1;
if (next > (levels.get(to) ?? 0)) {
levels.set(to, next);
changed = true;
}
}
}
for (const slug of slugs) {
if (!levels.has(slug)) {
levels.set(slug, 0);
}
if (!changed) break;
}
return levels;
}
function yOffsetInColumn(level: number, levelHeights: Map<number, number>): number {
function yOffsetInColumn(level: number, levelRowHeights: Map<number, number>): number {
let y = 0;
for (let l = 0; l < level; l++) {
y += (levelHeights.get(l) ?? 0) + GAP_Y;
y += (levelRowHeights.get(l) ?? 0) + GAP_Y;
}
return y;
}
type ChapterLayoutSlot = {
slug: string;
columnX: number;
level: number;
height: number;
sortIndex: number;
};
/** Stack chapters that share a level within one strand column (avoids overlapping cards). */
function layoutChapterPositions(
slots: ChapterLayoutSlot[]
): Map<string, { x: number; y: number; height: number }> {
const byColumn = new Map<number, ChapterLayoutSlot[]>();
for (const slot of slots) {
const column = byColumn.get(slot.columnX) ?? [];
column.push(slot);
byColumn.set(slot.columnX, column);
}
const positions = new Map<string, { x: number; y: number; height: number }>();
for (const [columnX, columnSlots] of byColumn) {
const byLevel = new Map<number, ChapterLayoutSlot[]>();
for (const slot of columnSlots) {
const levelSlots = byLevel.get(slot.level) ?? [];
levelSlots.push(slot);
byLevel.set(slot.level, levelSlots);
}
const levelRowHeights = new Map<number, number>();
for (const [level, levelSlots] of byLevel) {
levelSlots.sort((a, b) => a.sortIndex - b.sortIndex);
let rowHeight = 0;
for (let i = 0; i < levelSlots.length; i++) {
rowHeight += levelSlots[i].height;
if (i < levelSlots.length - 1) rowHeight += GAP_Y;
}
levelRowHeights.set(level, rowHeight);
}
for (const [level, levelSlots] of byLevel) {
levelSlots.sort((a, b) => a.sortIndex - b.sortIndex);
let y = yOffsetInColumn(level, levelRowHeights);
for (const slot of levelSlots) {
positions.set(slot.slug, { x: columnX, y, height: slot.height });
y += slot.height + GAP_Y;
}
}
}
return positions;
}
function buildStrandChapterOrder(subject: CatalogSubject): Map<string, number> {
const order = new Map<string, number>();
for (const strand of subject.strands) {
strand.chapters.forEach((chapter, index) => {
order.set(chapter.slug, index);
});
}
return order;
}
function layoutNodes(
subject: CatalogSubject,
visible: Map<string, CatalogChapter>,
@ -181,34 +209,23 @@ function layoutNodes(
subject.id
);
const levelMaxHeightByStrand = new Map<string, Map<number, number>>();
for (const [slug] of visible) {
const strandId = slugToStrandId.get(slug);
if (!strandId) continue;
const level = levels.get(slug) ?? 0;
const height = heightBySlug.get(slug) ?? 0;
if (!levelMaxHeightByStrand.has(strandId)) {
levelMaxHeightByStrand.set(strandId, new Map());
}
const levelHeights = levelMaxHeightByStrand.get(strandId)!;
levelHeights.set(level, Math.max(levelHeights.get(level) ?? 0, height));
}
const positions = new Map<string, { x: number; y: number; height: number }>();
const strandChapterOrder = buildStrandChapterOrder(subject);
const slots: ChapterLayoutSlot[] = [];
for (const [slug] of visible) {
const strandId = slugToStrandId.get(slug);
const columnX = strandId ? strandColumnX.get(strandId) : 0;
const level = levels.get(slug) ?? 0;
const levelHeights = strandId ? levelMaxHeightByStrand.get(strandId) : undefined;
const height = heightBySlug.get(slug) ?? 0;
positions.set(slug, {
x: columnX ?? 0,
y: yOffsetInColumn(level, levelHeights ?? new Map()),
height,
const columnX = strandId ? (strandColumnX.get(strandId) ?? 0) : 0;
slots.push({
slug,
columnX,
level: levels.get(slug) ?? 0,
height: heightBySlug.get(slug) ?? 0,
sortIndex: strandChapterOrder.get(slug) ?? 0,
});
}
const positions = layoutChapterPositions(slots);
const groups: JSONCanvasNode[] = [];
const nodes: JSONCanvasNode[] = [];
const colors = canvasNodeColors();
@ -281,7 +298,7 @@ function toCanvasEdges(edgeList: CatalogGraphEdge[]): JSONCanvasEdge[] {
function edgesFromCanvas(canvas: JSONCanvas): CatalogGraphEdge[] {
const edges: CatalogGraphEdge[] = [];
for (const edge of canvas.edges) {
for (const edge of canvas.edges ?? []) {
const from = chapterSlugFromNodeId(edge.fromNode);
const to = chapterSlugFromNodeId(edge.toNode);
if (!from || !to) continue;
@ -295,7 +312,8 @@ export function relayoutCanvasChapterHeights(
canvas: JSONCanvas,
heightBySlug: Map<string, number>
): JSONCanvas | null {
const chapterNodes = canvas.nodes.filter(
const canvasNodes = canvas.nodes ?? [];
const chapterNodes = canvasNodes.filter(
(node) => node.type === "text" && node.id.startsWith("chapter-")
);
if (chapterNodes.length === 0) return null;
@ -312,36 +330,35 @@ export function relayoutCanvasChapterHeights(
mergedHeights.set(slug, Math.max(node.height ?? 0, heightBySlug.get(slug) ?? 0));
}
const levelMaxHeightByColumn = new Map<number, Map<number, number>>();
for (const node of chapterNodes) {
const slug = chapterSlugFromNodeId(node.id);
if (!slug) continue;
const level = levels.get(slug) ?? 0;
const height = mergedHeights.get(slug) ?? 0;
if (!levelMaxHeightByColumn.has(node.x)) {
levelMaxHeightByColumn.set(node.x, new Map());
}
const levelHeights = levelMaxHeightByColumn.get(node.x)!;
levelHeights.set(level, Math.max(levelHeights.get(level) ?? 0, height));
}
const slots: ChapterLayoutSlot[] = chapterNodes.map((node, index) => {
const slug = chapterSlugFromNodeId(node.id) ?? "";
return {
slug,
columnX: node.x,
level: levels.get(slug) ?? 0,
height: mergedHeights.get(slug) ?? node.height ?? 0,
sortIndex: node.y * 1000 + index,
};
});
const positions = layoutChapterPositions(slots);
let changed = false;
const updatedChapters = chapterNodes.map((node) => {
const slug = chapterSlugFromNodeId(node.id);
if (!slug) return node;
const level = levels.get(slug) ?? 0;
const levelHeights = levelMaxHeightByColumn.get(node.x) ?? new Map();
const height = mergedHeights.get(slug) ?? node.height ?? 0;
const y = yOffsetInColumn(level, levelHeights);
if (y !== node.y || height !== node.height) changed = true;
return { ...node, y, height };
const pos = positions.get(slug);
if (!pos) return node;
const height = pos.height;
if (pos.y !== node.y || height !== node.height) changed = true;
return { ...node, y: pos.y, height };
});
if (!changed) return null;
const chapterById = new Map(updatedChapters.map((node) => [node.id, node]));
let nodes = canvas.nodes.map((node) => {
let nodes = canvasNodes.map((node) => {
if (node.type === "text" && node.id.startsWith("chapter-")) {
return chapterById.get(node.id) ?? node;
}

View file

@ -13,6 +13,7 @@ function readConfig() {
homeUrl: "/",
browseUrl: "/catalog/",
aboutUrl: "/about/",
contributingUrl: "/contributing/",
githubUrl: "https://github.com/openfreebooks/openfreebooks",
};
}
@ -21,6 +22,7 @@ function readConfig() {
homeUrl: string;
browseUrl: string;
aboutUrl: string;
contributingUrl: string;
githubUrl: string;
};
}
@ -92,4 +94,10 @@ if (catalogRoot) {
});
}
if (document.getElementById("contributing-demo-graph") || document.getElementById("contributing-demo-views")) {
void import("./components/contributing-demos").then(({ mountContributingDemos }) => {
mountContributingDemos();
});
}
mountFooterYear();