learning-website-nextjs1-3 Exercise 1: The Types, and a Banner Reader ========================================================================== Every site needs the same answer to "what is this file?": its site, kind, title, course, chapter and dates. So the answer is one set of TypeScript types and one parser, in a package (@lw/content) that every app imports, and not something each site works out for itself. It reads what the Django project's importer reads, so the two can be compared (Exercise 3). Add packages/content (package.json depends on @lw/sites; a tsconfig that extends the base one): Save as packages/content/package.json: { "name": "@lw/content", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" }, "dependencies": { "@lw/sites": "*" }, "scripts": { "test": "node --test \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit" } } Save as packages/content/tsconfig.json: { "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"], "jsx": "preserve" }, "include": ["src/**/*.ts"] } Save as packages/content/src/types.ts: import type { SiteName } from "@lw/sites"; /** What kind of file a page is, decided by its banner comment. */ export type Kind = "course_chapter" | "sidebar" | "lesson" | "full_page" | "other"; /** An ISO date, "2026-10-07". Dates are kept as text so no time zone can move them by a day. */ export type IsoDate = string; /** One content file, described. Nothing here changes once it is made. */ export interface Page { /** Relative to the content folder, with forward slashes: "hungary/hungarian-basic-3/x.html" (the BASE-relative path). */ readonly path: string; readonly site: SiteName; readonly kind: Kind; readonly title: string; /** The banner's Topic line (at most 300 characters), or "". */ readonly summary: string; readonly courseName: string | null; readonly courseNo: number | null; readonly chapterNo: number | null; readonly created: IsoDate | null; readonly updated: IsoDate | null; } /** A folder of numbered chapters, such as hungary/hungarian-basic-3. */ export interface Course { readonly folder: string; readonly site: SiteName; readonly name: string; readonly courseNo: number | null; readonly chapters: readonly Page[]; } /** The fields a banner comment can hold. Only the ones really present exist. */ export interface Banner { kind: "course-chapter" | "sidebar" | "free-banner" | "full-page" | "no-banner"; title?: string | null; created?: string; updated?: string; headline?: string; /** Any "Key: value" line, with the key in lower case: course, chapter, file, topic, category, ... */ [field: string]: string | null | undefined; } /** A file that could not be turned into a Page, and why. */ export interface ContentError { readonly path: string; readonly message: string; } Decisions in the types: - A Page's path is RELATIVE to the content folder with forward slashes (the "BASE-relative path"). Nothing in the model knows where the content folder is on disk. - Dates are ISO text ("2026-10-07"), not Date objects, so no time zone can move a date by a day. - Everything is readonly, and absent values are null, never "" or 0 (summary is the one string, "" when empty). - Kind is a closed list; a banner with no Course line and no Category line is a "lesson". Save as packages/content/src/banner.ts: import type { Banner } from "./types.ts"; const KEY = /^([A-Za-z][A-Za-z ]*?):\s*(.*)$/; const DATES = /Date Created:\s*(\d{4}-\d{2}-\d{2})\s+Date Updated:\s*(\d{4}-\d{2}-\d{2})/; /** * Read the banner comment at the top of a content file (or the of a complete page). * Only fields that are really present are returned: older pages have no dates. */ export function parseBanner(text: string): Banner { if (text.trimStart().toLowerCase().startsWith("<!doctype")) { const title = /<title>([\s\S]*?)<\/title>/.exec(text); return { kind: "full-page", title: title ? (title[1] ?? "").trim() : null }; } const comment = /^\s*<!--([\s\S]*?)-->/.exec(text); if (!comment) return { kind: "no-banner" }; const block = comment[1] ?? ""; const info: Banner = { kind: "free-banner" }; const dates = DATES.exec(block); if (dates) { info.created = dates[1]; info.updated = dates[2]; } const lines = block .split(/\r?\n/) .map((line) => line.trim().replace(/^=+|=+$/g, "").trim()) .filter((line) => line !== ""); for (const line of lines) { const match = KEY.exec(line); const key = match?.[1]?.trim(); if (match && key && key !== "Date Created" && key !== "Date Updated") { const name = key.toLowerCase(); if (info[name] === undefined) info[name] = (match[2] ?? "").trim(); // the first one wins } } if (info["course"] !== undefined) info.kind = "course-chapter"; else if (info["category"] !== undefined) info.kind = "sidebar"; else { info.kind = "free-banner"; info.headline = lines[0] ?? ""; } return info; } The banner is the comment at the very top of the file ("<!-- ... -->"): lines like "Course: X" are read into fields, the "====" decoration is stripped, the first value of a repeated key wins, and the dates are found with their own pattern. A complete HTML page is recognised by its doctype and described by its <title>. A comment that is NOT at the top is not a banner. WHY THIS WORKS AS AN ANSWER --------------------------- The shape of a page is decided once, in types the compiler enforces, so a site cannot quietly invent a different idea of "a page".