learning-website-nextjs1-1 Exercise 1: A Workspace With One Shared Package ============================================================================== Node v24.18.0, npm 11.16.0, Next.js 16.4, React 19.3, TypeScript 5.9. One repository holds every site, so a change to the site map is made once. npm "workspaces" do this with no extra tool: the root package.json lists the folders that are packages, and npm install links them to each other instead of downloading them. Save as package.json: { "name": "learning-website-nextjs", "private": true, "workspaces": [ "apps/*", "packages/*" ], "scripts": { "dev:languages": "npm run dev -w @lw/languages", "dev:webdevelopment": "npm run dev -w @lw/webdevelopment", "dev:portfolio": "npm run dev -w @lw/portfolio", "build": "npm run build --workspaces --if-present", "build:languages": "npm run build -w @lw/languages", "test": "node --test \"packages/sites/src/**/*.test.ts\"", "typecheck": "npm run typecheck --workspaces --if-present" }, "engines": { "node": ">=22" }, "devDependencies": { "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", "typescript": "^5.9" } } Save as tsconfig.base.json: { "compilerOptions": { "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "module": "esnext", "moduleResolution": "bundler", "strict": true, "noUncheckedIndexedAccess": true, "skipLibCheck": true, "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, "allowImportingTsExtensions": true, "noEmit": true, "jsx": "preserve" } } Save as .gitignore: node_modules/ .next/ out/ *.tsbuildinfo next-env.d.ts The shared package is TypeScript SOURCE with no build step: "exports" points straight at src/index.ts. Every app compiles it as part of its own build (Exercise 2), so there is nothing to publish or to keep in step. Save as packages/sites/package.json: { "name": "@lw/sites", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" }, "scripts": { "test": "node --test \"src/**/*.test.ts\"" } } Save as packages/sites/src/index.ts: /** * The site map: the ONE place that says which site exists and which content folders it serves. * It is the same map as `sites_config.py` in the Django project and as Learning Website: Framework & Architecture 2. * Every app imports it from here, so no app keeps its own idea of what the other sites are. */ export const DOMAIN = "osztromok.com"; export const SITES = { languages: { title: "Languages", folders: ["france", "germany", "hungary", "japan", "culture"] }, webdevelopment: { title: "Web Development", folders: ["web-development", "web-platforms", "web-servers"] }, programming: { title: "Programming", folders: [ "programming", "software-development", "maths-for-programmers", "retro-computing", "game-development", "android-development", "ios-development", "databases", "blockchain-and-web3", "developer-tools", "projects", ], }, systems: { title: "Systems", folders: ["linux", "operating-systems", "networking", "cloud-and-devops", "security", "technical-support", "raspberry-pi", "windows"], }, ai: { title: "AI and Data", folders: ["ai", "data-science-and-ml"] }, humanities: { title: "Humanities", folders: ["history", "politics", "philosophy", "art-history", "classic-literature", "science-fiction", "sci-fi-project", "music", "football", "ethics", "study-methodologies", "science"], }, lifeskills: { title: "Life Skills", folders: ["practical-life-skills", "cookery", "freelancing-and-business-skills", "youtube"], }, creative: { title: "Creative Tools", folders: ["creative-and-design-tools", "audio-video-production", "office-and-productivity-software"], }, } as const; export type SiteName = keyof typeof SITES; export const SITE_NAMES = Object.keys(SITES) as SiteName[]; /** Pages under sidebar// follow their subject. */ export const SIDEBAR_ROUTES: Record = { ai: "ai", drinks: "lifeskills", football: "humanities", history: "humanities", linux: "systems", programming: "programming", "web-development": "webdevelopment", }; /** A few addresses are not content folders but still belong to a site. */ export const SPECIAL_PREFIXES: Record = { "resources/japanese": "languages", "resources/hungarian": "languages", }; export class NoSiteError extends Error { constructor(path: string) { super(`no site owns this path: ${path}`); this.name = "NoSiteError"; } } /** The site that owns a root-relative content path such as "hungary/hungarian-basic-3/x.html". */ export function siteForPath(path: string): SiteName { const parts = path.replace(/^\/+|\/+$/g, "").split("/"); const joined = parts.join("/"); for (const [prefix, site] of Object.entries(SPECIAL_PREFIXES)) { if (joined === prefix || joined.startsWith(prefix + "/")) return site; } const first = parts[0] ?? ""; if (first === "sidebar") { const subject = parts[1]; const owner = subject === undefined ? undefined : SIDEBAR_ROUTES[subject]; if (owner === undefined) throw new NoSiteError(path); return owner; } for (const name of SITE_NAMES) { if ((SITES[name].folders as readonly string[]).includes(first)) return name; } throw new NoSiteError(path); } export type Environment = "dev" | "prod"; /** Host name of a site: languages.osztromok.com in production, languages.localhost in development. */ export function siteHost(site: SiteName, env: Environment): string { return `${site}.${env === "prod" ? DOMAIN : "localhost"}`; } /** The development port of each app, so all of them can run at once. */ export const DEV_PORTS: Record = { portfolio: 3000, languages: 3001, webdevelopment: 3002, programming: 3003, systems: 3004, ai: 3005, humanities: 3006, lifeskills: 3007, creative: 3008, }; /** The address of a site (or of a path on it), for links from one site to another. */ export function siteUrl(site: SiteName, env: Environment, path = "/"): string { if (env === "prod") return `https://${siteHost(site, env)}${path}`; return `http://${siteHost(site, env)}:${DEV_PORTS[site]}${path}`; } /** Which set of addresses to build links with. Set LW_ENV=prod when building for the real domain. */ export function environment(): Environment { return process.env["LW_ENV"] === "prod" ? "prod" : "dev"; } This is the site map of the Django project (config/sites_config.py) in TypeScript: the same eight sites and 48 folders, the same sidebar and special-prefix rules, and a function that fails loudly (NoSiteError) for a path no site owns instead of guessing. Save as packages/sites/src/index.test.ts: import assert from "node:assert/strict"; import { test } from "node:test"; import { DEV_PORTS, NoSiteError, SITES, SITE_NAMES, siteForPath, siteHost, siteUrl, } from "./index.ts"; test("every folder belongs to exactly one site", () => { const seen = new Map(); for (const name of SITE_NAMES) { for (const folder of SITES[name].folders) { assert.equal(seen.get(folder), undefined, `${folder} is in ${seen.get(folder)} and ${name}`); seen.set(folder, name); } } assert.equal(seen.size, 48); // the same 48 folders as the Django project's site map }); test("a path belongs to the site that owns its first folder", () => { assert.equal(siteForPath("hungary/hungarian-basic-3/x.html"), "languages"); assert.equal(siteForPath("/linux/system-administration/"), "systems"); assert.equal(siteForPath("projects/learning-website-nextjs/x.html"), "programming"); }); test("sidebar pages follow their subject", () => { assert.equal(siteForPath("sidebar/football/cheat_sheet_x.html"), "humanities"); assert.equal(siteForPath("sidebar/linux/x.html"), "systems"); assert.throws(() => siteForPath("sidebar/unknown/x.html"), NoSiteError); assert.throws(() => siteForPath("sidebar"), NoSiteError); }); test("special prefixes win over their parent folder", () => { assert.equal(siteForPath("resources/japanese/kanji/kanji_x.html"), "languages"); assert.throws(() => siteForPath("resources/php/x.php"), NoSiteError); }); test("a path that no site owns fails loudly instead of guessing", () => { assert.throws(() => siteForPath("nonsense/x.html"), NoSiteError); assert.throws(() => siteForPath(""), NoSiteError); }); test("hosts and addresses", () => { assert.equal(siteHost("languages", "prod"), "languages.osztromok.com"); assert.equal(siteHost("languages", "dev"), "languages.localhost"); assert.equal(siteUrl("systems", "prod", "/linux/"), "https://systems.osztromok.com/linux/"); assert.equal(siteUrl("systems", "dev", "/linux/"), "http://systems.localhost:3004/linux/"); }); test("every site has its own development port", () => { const ports = Object.values(DEV_PORTS); assert.equal(new Set(ports).size, ports.length); for (const name of SITE_NAMES) assert.ok(name in DEV_PORTS, name); }); Run it. No test library is needed: Node's own test runner runs TypeScript files directly. npm install added 31 packages, and audited 36 packages in 27s (node_modules is 369 MB) found 0 vulnerabilities npm test ✔ every folder belongs to exactly one site (0.6334ms) ✔ a path belongs to the site that owns its first folder (0.2226ms) ✔ sidebar pages follow their subject (0.3626ms) ✔ special prefixes win over their parent folder (0.1085ms) ✔ a path that no site owns fails loudly instead of guessing (0.1151ms) ✔ hosts and addresses (0.1041ms) ✔ every site has its own development port (0.1269ms) ℹ tests 7 ℹ pass 7 ℹ fail 0 WHAT THE INSTALL DID: node_modules/@lw/sites, node_modules/@lw/languages and the other two are LINKS to the real folders, so an app importing "@lw/sites" gets the live source file, not a copy. NOTE: node_modules is 369 MB, which is why it is not in git and why it is best installed outside a synchronised folder such as OneDrive. WHY THIS WORKS AS AN ANSWER --------------------------- The one thing every site needs to agree on (which folders belong to which site) lives in one tested file, and its test checks that no folder is claimed by two sites and that the count is the same 48 as the Django map. (The first run of "npm test" failed only because the command named a folder instead of a file pattern; once that was fixed, all 7 tests passed.)