learning-website-nextjs1-7 Exercise 1: Group the Courses the Way a Learner Thinks, and Prove It Against Django ============================================================================================================== The languages front page is not an alphabetical list of folders. It shows each language and, inside it, its courses in the order a learner meets them: Reading and writing alphabets and kana Survival Basic Conversation courses 1 to 3 Everyday Basic Conversation courses 4 to 6 Culture courses about the country, not the language Other courses anything else with numbered chapters All of this is decided by plain functions in a package of its own (named @lw/languages-site, so as not to be confused with the app @lw/languages), so it can be tested without a browser. Save as packages/languages/package.json: { "name": "@lw/languages-site", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" }, "dependencies": { "@lw/content": "*" }, "scripts": { "test": "node --test \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit" } } Save as packages/languages/src/config.ts: /** What the languages site knows about each language: its folder, name, language code and colours. */ export interface Language { /** The top-level folder, which is also the key in the address: hungary/... */ readonly key: string; readonly name: string; /** The HTML language code of the language being taught. */ readonly code: string; /** The language's own colour (borders, backgrounds). */ readonly accent: string; /** The colour to use for TEXT on the dark surface. Japanese's red is only 3.86 against it (WCAG AA needs 4.5), so its text is a lighter red of the same hue. */ readonly text: string; /** The folder of standalone lessons and reference pages, or null. */ readonly lessonsFolder: string | null; } export const LANGUAGES: readonly Language[] = [ { key: "france", name: "French", code: "fr", accent: "#4f8ef7", text: "#4f8ef7", lessonsFolder: "france/french-lessons" }, { key: "germany", name: "German", code: "de", accent: "#e0a82e", text: "#e0a82e", lessonsFolder: null }, { key: "hungary", name: "Hungarian", code: "hu", accent: "#5aa469", text: "#5aa469", lessonsFolder: "hungary/hungarian-lessons" }, { key: "japan", name: "Japanese", code: "ja", accent: "#d64550", text: "#e8707a", lessonsFolder: "japan/japanese-language" }, ]; Save as packages/languages/src/grouping.ts: import type { Course } from "@lw/content"; import { LANGUAGES, type Language } from "./config.ts"; /** * Group the language courses the way a learner thinks about them: * * Reading and writing alphabets and kana * Survival Basic Conversation courses 1 to 3 * Everyday Basic Conversation courses 4 to 6 * Culture courses about the country, not the language * Other courses anything else with numbered chapters */ const WRITING = ["alphabet", "hiragana", "katakana", "kanji"] as const; export const GROUP_ORDER = ["Reading and writing", "Survival", "Everyday", "Culture", "Other courses"] as const; type GroupTitle = (typeof GROUP_ORDER)[number]; /** hungary/hungarian-basic-3 gives hungary; culture/japan/japanese-music gives japan. */ export function languageKey(folder: string): string { const parts = folder.split("/"); return parts[0] === "culture" && parts.length > 1 ? (parts[1] as string) : (parts[0] as string); } /** Text order by code point (what Python's sort does), not by the browser's language rules. */ function compareText(a: string, b: string): number { return Buffer.compare(Buffer.from(a), Buffer.from(b)); } type SortKey = readonly (string | number)[]; function compareKeys(a: SortKey, b: SortKey): number { for (let i = 0; i < Math.min(a.length, b.length); i++) { const x = a[i] as string | number; const y = b[i] as string | number; if (x === y) continue; return typeof x === "string" && typeof y === "string" ? compareText(x, y) : (x as number) < (y as number) ? -1 : 1; } return a.length - b.length; } /** The group a course belongs to, and its place within the group. */ export function classify(course: Pick): { group: GroupTitle; key: SortKey } { const name = course.folder.slice(course.folder.lastIndexOf("/") + 1); if (course.folder.startsWith("culture/")) return { group: "Culture", key: [course.name] }; const writing = WRITING.findIndex((word) => name.includes(word)); if (writing >= 0) return { group: "Reading and writing", key: [writing, course.courseNo ?? 0] }; if (name.includes("-basic-")) { const number = course.courseNo ?? 0; return { group: number <= 3 ? "Survival" : "Everyday", key: [number] }; } return { group: "Other courses", key: [course.name] }; } export interface Group { readonly title: string; readonly courses: readonly Course[]; } export interface Section extends Language { readonly groups: readonly Group[]; } /** One section per language, in the order of LANGUAGES, with its groups in a fixed order. */ export function buildSections(courses: readonly Course[]): Section[] { const byLanguage = new Map>(); for (const course of courses) { const { group, key } = classify(course); const language = languageKey(course.folder); const groups = byLanguage.get(language) ?? new Map(); byLanguage.set(language, groups); groups.set(group, [...(groups.get(group) ?? []), { key, course }]); } return LANGUAGES.map((language) => { const grouped = byLanguage.get(language.key); const groups: Group[] = []; for (const title of GROUP_ORDER) { const members = grouped?.get(title); if (!members) continue; const label = title === "Survival" || title === "Everyday" ? `${title} ${language.name}` : title; groups.push({ title: label, courses: [...members].sort((a, b) => compareKeys(a.key, b.key)).map((m) => m.course) }); } return { ...language, groups }; }); } Save as packages/languages/src/index.ts: export { LANGUAGES } from "./config.ts"; export type { Language } from "./config.ts"; export { GROUP_ORDER, buildSections, classify, languageKey } from "./grouping.ts"; export type { Group, Section } from "./grouping.ts"; Two details: - Japanese has TWO colours. Its red (#d64550) is only 3.86 against the dark surface (the readable minimum for text is 4.5), so the section keeps the true red for its border and uses a lighter red of the same hue (#e8707a) for its heading text. A test keeps every language's TEXT colour at 4.5 or more, and checks that Japanese's accent alone would fail, which is why the second colour exists. - Sorting compares text by code point (as the Django project does) and NOT with localeCompare, which follows the browser's language rules. The two give different orders for "Banana", "Zebra" and "apple". Save as packages/languages/src/languages.test.ts: import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; import type { Course, Page } from "@lw/content"; import { LANGUAGES, buildSections, classify, languageKey } from "./index.ts"; function course(folder: string, name: string, courseNo: number | null = null): Course { return { folder, site: "languages", name, courseNo, chapters: [] as readonly Page[] }; } test("the language is the first folder, or the second under culture", () => { assert.equal(languageKey("hungary/hungarian-basic-3"), "hungary"); assert.equal(languageKey("culture/japan/japanese-music"), "japan"); assert.equal(languageKey("culture"), "culture"); }); test("courses fall into the groups a learner thinks in", () => { const group = (folder: string, name = "N", no: number | null = null) => classify(course(folder, name, no)).group; assert.equal(group("hungary/hungarian-alphabet-1", "A", 1), "Reading and writing"); assert.equal(group("japan/hiragana-1", "H", 1), "Reading and writing"); assert.equal(group("japan/katakana-2", "K", 2), "Reading and writing"); assert.equal(group("japan/kanji-1", "K", 1), "Reading and writing"); assert.equal(group("germany/german-basic-1", "G", 1), "Survival"); assert.equal(group("germany/german-basic-3", "G", 3), "Survival"); assert.equal(group("germany/german-basic-4", "G", 4), "Everyday"); assert.equal(group("culture/japan/japanese-music", "M"), "Culture"); assert.equal(group("france/french-lessons", "F"), "Other courses"); assert.equal(group("germany/german-basic-x", "G", null), "Survival"); // no number counts as 0 }); test("a language's sections come in a fixed order, and empty groups are left out", () => { const sections = buildSections([ course("germany/german-basic-4", "German Basic 4", 4), course("germany/german-basic-1", "German Basic 1", 1), course("culture/germany/german-film", "German Film"), ]); assert.deepEqual(sections.map((s) => s.name), ["French", "German", "Hungarian", "Japanese"]); // the order of LANGUAGES assert.deepEqual(sections[0]?.groups, []); // French has nothing here assert.deepEqual(sections[1]?.groups.map((g) => g.title), ["Survival German", "Everyday German", "Culture"]); }); test("within a group: courses by number; culture courses by name, in code-point order", () => { const sections = buildSections([ course("hungary/hungarian-basic-3", "B3", 3), course("hungary/hungarian-alphabet-1", "A1", 1), course("hungary/hungarian-basic-1", "B1", 1), course("hungary/hungarian-basic-2", "B2", 2), course("culture/hungary/z-music", "Zebra"), course("culture/hungary/a-film", "apple"), course("culture/hungary/b-art", "Banana"), ]); const hungarian = sections.find((s) => s.key === "hungary")!; assert.deepEqual(hungarian.groups.find((g) => g.title === "Survival Hungarian")?.courses.map((c) => c.name), ["B1", "B2", "B3"]); // capital letters sort before small ones by code point: Banana, Zebra, apple (a browser's rule would give apple first) assert.deepEqual(hungarian.groups.find((g) => g.title === "Culture")?.courses.map((c) => c.name), ["Banana", "Zebra", "apple"]); }); test("a course of a language that is not on the site is not shown", () => { const sections = buildSections([course("spain/spanish-basic-1", "Spanish", 1)]); assert.ok(sections.every((s) => s.groups.length === 0)); }); test("each language has its own colour and a readable text colour on the surface", () => { const tokens = readFileSync(new URL("../../ui/src/tokens.css", import.meta.url), "utf8"); const surface = /\[data-family="language"\]\s*\{[^}]*--surface:\s*(#[0-9a-f]{6})/i.exec(tokens)?.[1]; assert.ok(surface); const luminance = (hex: string) => { const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) as [number, number, number]; const f = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; const contrast = (a: string, b: string) => { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]; return (hi + 0.05) / (lo + 0.05); }; assert.equal(new Set(LANGUAGES.map((l) => l.accent)).size, LANGUAGES.length); for (const language of LANGUAGES) { assert.ok(contrast(language.text, surface) >= 4.5, `${language.name} text ${language.text}: ${contrast(language.text, surface).toFixed(2)}`); } const japanese = LANGUAGES.find((l) => l.key === "japan")!; assert.ok(contrast(japanese.accent, surface) < 4.5); // why Japanese needs a separate text colour at all }); npm test ℹ tests 56 ℹ pass 56 ℹ fail 0 (50 earlier and 6 new) The proof. dumplangs.py (in the Django project) writes what its front page holds, for the real content; compare-languages.mjs asks the TypeScript version the same question: Save as compare-languages.mjs: // Compare the languages front page's grouping with the Django project's, on the real content. // node compare-languages.mjs import { readFileSync } from "node:fs"; import { groupCourses, loadPages } from "./packages/content/src/index.ts"; import { buildSections } from "./packages/languages/src/index.ts"; const [root, dumpPath] = process.argv.slice(2); const django = JSON.parse(readFileSync(dumpPath, "utf8")); const { pages } = loadPages(root, { site: "languages" }); const courses = groupCourses(pages).filter((c) => c.site === "languages"); const sections = buildSections(courses); const mine = sections.map((s) => ({ key: s.key, name: s.name, accent: s.accent, text: s.text, lessons: s.lessonsFolder ? pages.filter((p) => p.path.startsWith(s.lessonsFolder + "/")).length : 0, groups: s.groups.map((g) => ({ title: g.title, courses: g.courses.map((c) => [c.folder, c.name, c.chapters.length]) })), })); let bad = 0; for (const [i, expected] of django.entries()) { const same = JSON.stringify(mine[i]) === JSON.stringify(expected); const total = expected.groups.reduce((n, g) => n + g.courses.length, 0); console.log(`${expected.name.padEnd(10)} ${expected.groups.length} groups, ${String(total).padStart(2)} courses, ${String(expected.lessons).padStart(2)} lessons: ${same ? "identical" : "DIFFERENT"}`); if (!same) { bad++; console.log(" mine: ", JSON.stringify(mine[i]).slice(0, 400)); console.log(" django:", JSON.stringify(expected).slice(0, 400)); } } console.log(`${courses.length} courses on the site; ${bad} sections different`); for (const s of sections) console.log(` ${s.name}: ` + s.groups.map((g) => `${g.title} (${g.courses.length})`).join(", ")); French 1 groups, 1 courses, 3 lessons: identical German 2 groups, 4 courses, 0 lessons: identical Hungarian 2 groups, 4 courses, 12 lessons: identical Japanese 3 groups, 11 courses, 300 lessons: identical 20 courses on the site; 0 sections different French: Survival French (1) German: Survival German (3), Everyday German (1) Hungarian: Reading and writing (1), Survival Hungarian (3) Japanese: Reading and writing (5), Survival Japanese (1), Culture (5) Every section is identical: the groups, their titles, the courses in them, the chapter counts and the lessons counts. Can it fail? Two deliberate breakages, each undone: "Basic 3" treated as Everyday (<= 2): German and Hungarian different (2 sections) <-- caught sort changed to localeCompare: 0 different on the real content <-- NOT caught The second shows something honest: on the real content the five Japanese culture courses sort the same either way, so the comparison cannot see the difference. The UNIT TEST above can (its names are Banana, Zebra and apple), and does fail with that change (55 passed, 1 failed). The takeaway: real data proves the cases it contains, and a test with chosen awkward data covers the rest. WHY THIS WORKS AS AN ANSWER --------------------------- The grouping rules are written once, checked against the real site from two angles (the other implementation's output, and hand-made awkward cases), and each check has been seen to fail.