learning-website-nextjs1-6 Exercise 1: Navigation That Is Worked Out From the Pages, and Proved Against Django ========================================================================================================= There are three layers of navigation: the bar of every site, the site's own menu of subjects, and the page's breadcrumb and previous/next links. None of them is written by hand. All are DERIVED from the pages, so none can drift from the content. The logic is a package of plain functions, @lw/navigation, with no React in it, so it can be tested without a browser. Save as packages/navigation/package.json: { "name": "@lw/navigation", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" }, "dependencies": { "@lw/content": "*", "@lw/sites": "*" }, "scripts": { "test": "node --test \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit" } } Save as packages/navigation/src/labels.ts: /** * Readable labels for folder names, so a menu says "Data Science and ML", not "Data Science And Ml". * Small joining words stay lower case; acronyms and a few product names are known. */ const SMALL_WORDS = new Set(["a", "an", "and", "of", "for", "the", "in", "on", "to", "with", "vs"]); const ACRONYMS = new Set([ "ai", "css", "html", "js", "php", "sql", "api", "seo", "tls", "https", "ssh", "vpn", "rdp", "vnc", "grub", "awk", "sed", "iis", "obs", "vfx", "pdf", "k8s", "llm", "nlp", "gpu", "cpu", "os", "ui", "ux", "cli", "sdk", "ide", "json", "xml", "yaml", "rest", "grpc", "cicd", "iot", "orm", "jwt", "oauth", "cdn", "ml", "dns", "aws", "gcp", "usa", "uk", ]); const OVERRIDES: Record = { devops: "DevOps", ios: "iOS", macos: "macOS", nextjs: "Next.js", nodejs: "Node.js", javascript: "JavaScript", typescript: "TypeScript", graphql: "GraphQL", mysql: "MySQL", postgresql: "PostgreSQL", sqlite: "SQLite", mongodb: "MongoDB", wordpress: "WordPress", web3: "Web3", youtube: "YouTube", linkedin: "LinkedIn", github: "GitHub", }; /** 'data-science-and-ml' becomes 'Data Science and ML'. */ export function folderLabel(segment: string): string { const stem = segment.replace(/\.[a-z0-9]+$/i, ""); const words = stem.split(/[-_]+/).filter((word) => word !== ""); return words .map((word, i) => { const low = word.toLowerCase(); if (Object.hasOwn(OVERRIDES, low)) return OVERRIDES[low] as string; if (ACRONYMS.has(low)) return low.toUpperCase(); if (i > 0 && SMALL_WORDS.has(low)) return low; return low.charAt(0).toUpperCase() + low.slice(1); }) .join(" "); } Save as packages/navigation/src/nav.ts: import type { Page } from "@lw/content"; import { SIDEBAR_ROUTES, SITES, SITE_NAMES, siteUrl, type Environment, type SiteName } from "@lw/sites"; import { folderLabel } from "./labels.ts"; /** * The three layers of navigation: the global bar, the site menu, and the page's own breadcrumb and previous/next * links. All of them are DERIVED from the pages, so none can drift from the content. */ export interface GlobalBarItem { readonly name: SiteName; readonly title: string; readonly url: string; readonly current: boolean } export interface MenuItem { readonly label: string; readonly href: string; readonly folder: string } export interface Crumb { readonly label: string; readonly href: string | null } export interface Listing { readonly folders: readonly { readonly label: string; readonly href: string; readonly count: number }[]; readonly pages: readonly Page[]; readonly courseName: string | null; } /** Layer 1: every site, the same on every page, with the current one marked. */ export function globalBar(current: SiteName | null, env: Environment): GlobalBarItem[] { return SITE_NAMES.map((name) => ({ name, title: SITES[name].title, url: siteUrl(name, env), current: name === current })); } /** Text order by UTF-8 bytes, which is the order a database or the Django project sorts in. */ function compareText(a: string, b: string): number { return Buffer.compare(Buffer.from(a), Buffer.from(b)); } /** Chapter number first (a missing number sorts first), then the path. */ function byChapterThenPath(a: Page, b: Page): number { const an = a.chapterNo ?? -Infinity; const bn = b.chapterNo ?? -Infinity; return an === bn ? compareText(a.path, b.path) : an < bn ? -1 : 1; } const folderOf = (path: string) => path.slice(0, path.lastIndexOf("/")); export class NavIndex { readonly site: SiteName; private readonly pages: readonly Page[]; private readonly under = new Map(); // folder -> every page below it private readonly chapters = new Map(); // course folder -> its numbered chapters private readonly courseNames = new Map(); // course folder -> the course's name constructor(site: SiteName, pages: readonly Page[]) { this.site = site; this.pages = pages.filter((page) => page.site === site); for (const page of [...this.pages].sort((a, b) => compareText(a.path, b.path))) { const parts = page.path.split("/"); for (let i = 1; i < parts.length; i++) { const folder = parts.slice(0, i).join("/"); const list = this.under.get(folder); if (list) list.push(page); else this.under.set(folder, [page]); } if (page.kind === "course_chapter") { const folder = folderOf(page.path); if (!this.courseNames.has(folder)) this.courseNames.set(folder, page.courseName ?? folder); if (page.chapterNo !== null) { const list = this.chapters.get(folder); if (list) list.push(page); else this.chapters.set(folder, [page]); } } } } /** Layer 2: this site's subject folders that really have pages, in the site map's order. */ menu(): MenuItem[] { const present = new Set(this.pages.map((page) => page.path.split("/")[0])); const items: MenuItem[] = (SITES[this.site].folders as readonly string[]) .filter((folder) => present.has(folder)) .map((folder) => ({ label: folderLabel(folder), href: `/${folder}/`, folder })); // a site only gets a Sidebar entry if some sidebar// is routed to it AND has pages if (present.has("sidebar") && Object.values(SIDEBAR_ROUTES).includes(this.site)) { items.push({ label: "Sidebar", href: "/sidebar/", folder: "sidebar" }); } return items; } /** * Layer 3: Site > folder > folder > ... > this page. `folderPath` is the page's folder, "" for the site home. * A course folder is labelled with the course's real name; any other folder with its readable name. */ breadcrumbs(folderPath: string, lastLabel?: string): Crumb[] { const parts = folderPath.split("/").filter((part) => part !== ""); const trail: { label: string; href: string | null }[] = [{ label: SITES[this.site].title, href: "/" }]; parts.forEach((part, i) => { const prefix = parts.slice(0, i + 1).join("/"); trail.push({ label: this.courseNames.get(prefix) || folderLabel(part), href: `/${prefix}/` }); }); if (lastLabel) trail.push({ label: lastLabel, href: null }); else if (trail[trail.length - 1]?.href !== "/") trail[trail.length - 1]!.href = null; // the folder you are on is not a link return trail; } /** The previous and next chapter of the same course, by chapter NUMBER (so 10 follows 9, not 1). */ neighbours(page: Page): { previous: Page | null; next: Page | null } { if (page.kind !== "course_chapter" || page.chapterNo === null) return { previous: null, next: null }; const all = this.chapters.get(folderOf(page.path)) ?? []; const n = page.chapterNo; const before = all.filter((p) => (p.chapterNo as number) < n).sort((a, b) => (b.chapterNo as number) - (a.chapterNo as number) || compareText(a.path, b.path)); const after = all.filter((p) => (p.chapterNo as number) > n).sort((a, b) => (a.chapterNo as number) - (b.chapterNo as number) || compareText(a.path, b.path)); return { previous: before[0] ?? null, next: after[0] ?? null }; } /** Is there a folder with pages below it? */ hasFolder(folderPath: string): boolean { return this.under.has(folderPath.replace(/^\/+|\/+$/g, "")); } /** Every folder that has pages below it, for building a listing page for each. */ folders(): string[] { return [...this.under.keys()].sort(compareText); } /** What is directly inside a folder: sub-folders and pages. null if the folder has no pages. */ listing(folderPath: string): Listing | null { const folder = folderPath.replace(/^\/+|\/+$/g, ""); const below = this.under.get(folder); if (!below) return null; const prefix = folder + "/"; const folders = new Map(); const here: Page[] = []; for (const page of [...below].sort(byChapterThenPath)) { const rest = page.path.slice(prefix.length); const slash = rest.indexOf("/"); if (slash >= 0) { const name = rest.slice(0, slash); folders.set(name, (folders.get(name) ?? 0) + 1); } else { here.push(page); } } return { folders: [...folders.entries()].sort(([a], [b]) => compareText(a, b)) .map(([name, count]) => ({ label: folderLabel(name), href: `/${prefix}${name}/`, count })), pages: here, courseName: this.courseNames.get(folder) ?? null, }; } } Save as packages/navigation/src/links.ts: import { siteForPath, siteUrl, type Environment, type SiteName } from "@lw/sites"; /** * Turn an old-style root-relative link into the right link from `current`, using the one site map. * * - external links, anchors, relative paths and .txt solution files are returned unchanged * - a link into the current site stays root-relative (the trailing slash, query and fragment are kept) * - a link into another site becomes an absolute address on that site * - a root-relative path that belongs to no site throws NoSiteError: a broken link should fail, not be guessed */ export function resolveLink(current: SiteName, href: string, env: Environment): string { if (!href.startsWith("/") || href.startsWith("//") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href)) return href; const path = href.split(/[?#]/)[0] ?? href; if (path.toLowerCase().endsWith(".txt") || path === "/") return href; const target = siteForPath(path); return target === current ? href : siteUrl(target, env, href); } Save as packages/navigation/src/index.ts: export { folderLabel } from "./labels.ts"; export { resolveLink } from "./links.ts"; export { NavIndex, globalBar } from "./nav.ts"; export type { Crumb, GlobalBarItem, Listing, MenuItem } from "./nav.ts"; What each part does: - Labels: a folder name becomes a readable label: "data-science-and-ml" is "Data Science and ML", "cloud-and-devops" is "Cloud and DevOps". Small joining words stay lower case, a known list of acronyms is upper-cased, a few product names have fixed spellings, and the first word is always capitalised. - globalBar: every site, with the current one marked; the addresses come from the one site map, so they differ between development and production by themselves. - menu: this site's subject folders that really have pages, in the site map's order. A "Sidebar" entry appears only on a site that a sidebar subject is routed to and that has pages. - breadcrumbs: Site, folder, folder, then the page. A course folder is labelled with the COURSE'S NAME, not its folder name. - neighbours: the previous and next chapter of the same course, by chapter NUMBER (so 10 follows 9, not 1). - listing: what is directly inside a folder: sub-folders with page counts, and pages in chapter order. - resolveLink: one root-relative link, seen from one site, becomes the right link: unchanged if it is inside the site, an absolute address on the other site if it is not, and an error if no site owns it. External links, anchors and .txt solutions are left alone. A limit of running TypeScript in Node: it runs TypeScript by stripping the types, so a constructor written as "constructor(readonly site: SiteName, ...)" (a "parameter property") is refused ("not supported in strip-only mode"). The first version of NavIndex used one and could not be run; the field is now declared on its own line. The takeaway: write TypeScript that is only types plus JavaScript, and the same files run in Node, in tests and in Next. Save as packages/navigation/src/navigation.test.ts: import assert from "node:assert/strict"; import { test } from "node:test"; import type { Page } from "@lw/content"; import { NoSiteError } from "@lw/sites"; import { NavIndex, folderLabel, globalBar, resolveLink } from "./index.ts"; function page(path: string, extra: Partial = {}): Page { return { path, site: "languages", kind: "course_chapter", title: path, summary: "", courseName: "Course", courseNo: 1, chapterNo: null, created: null, updated: null, ...extra, }; } test("folder labels: small words, acronyms and product names", () => { assert.equal(folderLabel("data-science-and-ml"), "Data Science and ML"); assert.equal(folderLabel("cloud-and-devops"), "Cloud and DevOps"); assert.equal(folderLabel("the-art-of-war"), "The Art of War"); // the first word is always capitalised assert.equal(folderLabel("ios-development"), "iOS Development"); assert.equal(folderLabel("nextjs_basics"), "Next.js Basics"); assert.equal(folderLabel("hungarian_lesson.html"), "Hungarian Lesson"); // an extension is dropped assert.equal(folderLabel("constructor"), "Constructor"); // not a property lookup assert.equal(folderLabel(""), ""); }); test("the global bar lists every site and marks the current one", () => { const bar = globalBar("systems", "dev"); assert.equal(bar.length, 8); assert.deepEqual(bar.filter((item) => item.current).map((item) => item.name), ["systems"]); assert.equal(bar.find((item) => item.name === "ai")?.url, "http://ai.localhost:3005/"); assert.equal(globalBar(null, "prod").every((item) => !item.current), true); }); test("links: a link into another site gets that site's address, everything else is left alone", () => { assert.equal(resolveLink("languages", "/linux/x/?q=1#top", "dev"), "http://systems.localhost:3004/linux/x/?q=1#top"); assert.equal(resolveLink("languages", "/hungary/x/", "dev"), "/hungary/x/"); assert.equal(resolveLink("languages", "/linux/x/", "prod"), "https://systems.osztromok.com/linux/x/"); for (const same of ["https://example.com/linux/", "//cdn.example.com/x", "mailto:a@b.c", "#top", "relative/linux/", "/", "/linux/a/s1.txt"]) { assert.equal(resolveLink("languages", same, "dev"), same, same); } assert.throws(() => resolveLink("languages", "/nonsense/x/", "dev"), NoSiteError); }); const pages = [ page("hungary/c/c_1_1.html", { chapterNo: 1 }), page("hungary/c/c_1_2.html", { chapterNo: 2 }), page("hungary/c/c_1_10.html", { chapterNo: 10 }), page("hungary/c/appendix.html"), page("hungary/other/lesson_a.html", { kind: "lesson", courseName: null }), page("japan/j/j_1_1.html", { chapterNo: 1, courseName: "Japanese 1" }), page("linux/x/y_1_1.html", { site: "systems", chapterNo: 1 }), ]; test("the site menu lists the site's folders that have pages, in the site map's order", () => { const nav = new NavIndex("languages", pages); assert.deepEqual(nav.menu().map((item) => item.href), ["/hungary/", "/japan/"]); // france, germany, culture have no pages assert.equal(new NavIndex("systems", pages).menu()[0]?.label, "Linux"); assert.deepEqual(new NavIndex("ai", pages).menu(), []); }); test("a Sidebar entry appears only on a site that a sidebar subject is routed to, and only with pages", () => { const sidebar = [page("sidebar/football/cheat_sheet_x.html", { site: "humanities", kind: "sidebar", courseName: null })]; assert.deepEqual(new NavIndex("humanities", sidebar).menu().map((i) => i.label), ["Sidebar"]); assert.deepEqual(new NavIndex("languages", sidebar).menu(), []); }); test("chapters follow each other by number, so 10 comes after 2", () => { const nav = new NavIndex("languages", pages); const get = (path: string) => pages.find((p) => p.path === path) as Page; assert.deepEqual(nav.neighbours(get("hungary/c/c_1_1.html")), { previous: null, next: get("hungary/c/c_1_2.html") }); assert.deepEqual(nav.neighbours(get("hungary/c/c_1_2.html")), { previous: get("hungary/c/c_1_1.html"), next: get("hungary/c/c_1_10.html") }); assert.equal(nav.neighbours(get("hungary/c/c_1_10.html")).next, null); assert.deepEqual(nav.neighbours(get("hungary/c/appendix.html")), { previous: null, next: null }); // no number assert.deepEqual(nav.neighbours(get("hungary/other/lesson_a.html")), { previous: null, next: null }); // not a course chapter }); test("breadcrumbs: course folders use the course's name, the last one is not a link", () => { const nav = new NavIndex("languages", pages); assert.deepEqual(nav.breadcrumbs("hungary/c", "Chapter title"), [ { label: "Languages", href: "/" }, { label: "Hungary", href: "/hungary/" }, { label: "Course", href: "/hungary/c/" }, { label: "Chapter title", href: null }, ]); assert.deepEqual(nav.breadcrumbs("hungary/c").at(-1), { label: "Course", href: null }); assert.deepEqual(nav.breadcrumbs(""), [{ label: "Languages", href: "/" }]); }); test("a folder listing has its sub-folders with counts and its own pages in chapter order", () => { const nav = new NavIndex("languages", pages); const top = nav.listing("hungary"); assert.deepEqual(top?.folders, [{ label: "C", href: "/hungary/c/", count: 4 }, { label: "Other", href: "/hungary/other/", count: 1 }]); assert.deepEqual(top?.pages, []); const course = nav.listing("hungary/c/"); assert.deepEqual(course?.pages.map((p) => p.path), ["hungary/c/appendix.html", "hungary/c/c_1_1.html", "hungary/c/c_1_2.html", "hungary/c/c_1_10.html"]); assert.equal(course?.courseName, "Course"); assert.equal(nav.listing("hungary/missing"), null); assert.equal(nav.listing("linux"), null); // another site's folder is not listed here }); test("a folder exists only if pages are below it", () => { const nav = new NavIndex("languages", pages); assert.ok(nav.hasFolder("hungary") && nav.hasFolder("/hungary/c/")); assert.ok(!nav.hasFolder("hungary/c/c_1_1") && !nav.hasFolder("france") && !nav.hasFolder("linux")); assert.deepEqual(nav.folders(), ["hungary", "hungary/c", "hungary/other", "japan", "japan/j"]); }); npm test ℹ tests 50 ℹ pass 50 ℹ fail 0 (41 from earlier chapters and 9 new) Now the proof against the Django project. dumpnav.py (in the Django project) writes what its navigation says for the real content: every site's menu, the previous and next chapter of every page, the breadcrumb of every page, and the listing of every folder. compare-nav.mjs asks the TypeScript version the same questions: Save as compare-nav.mjs: // Compare the TypeScript navigation with the Django project's, on the real content. // node compare-nav.mjs