dev.ts
6.0 kB · typescript · 171 lines
1import { existsSync, statSync, watch } from "node:fs";2import { extname, join, resolve } from "node:path";3import { forget, globals, render, scan, type Output, type Route, type Site } from "../kit/ssg/build.ts";4import { owner as rawOwner } from "../kit/git/git.ts";5import { counted, demoTree, spec } from "./site.ts";67const org = resolve(import.meta.dir, "..");8const cached = join(org, "data", "shelf", "research");9if (!process.env.MRLY_SHELF && existsSync(join(cached, "README.md"))) process.env.MRLY_SHELF = cached;1011/* TYPES */1213const TYPES: Record<string, string> = {14 ".css": "text/css; charset=utf-8",15 ".html": "text/html; charset=utf-8",16 ".js": "text/javascript; charset=utf-8",17 ".json": "application/json",18 ".md": "text/markdown; charset=utf-8",19 ".pdf": "application/pdf",20 ".png": "image/png",21 ".svg": "image/svg+xml",22 ".tex": "text/plain; charset=utf-8",23 ".txt": "text/plain; charset=utf-8",24 ".wasm": "application/wasm",25 ".webmanifest": "application/manifest+json",26 ".xml": "application/xml",27};2829const type = (path: string) => TYPES[extname(path)] ?? "application/octet-stream";3031const send = (item: Output, status = 200) =>32 new Response(item.bytes as string | Uint8Array, { status, headers: { "content-type": item.type ?? type(item.path) } });3334/* SCAN */3536let site: Site = await scan(spec);37let extra: Map<string, Output> | null = null;3839async function assets() {40 if (!extra) extra = new Map((await globals(site, spec)).map((item) => [item.path, item]));41 return extra;42}4344function watched() {45 const paths = new Set<string>();46 for (const one of Object.values(site.inputs)) if (!one.missing) paths.add(one.path);47 if (site.kit) paths.add(site.kit.path);48 for (const dir of spec.templates ?? []) paths.add(join(org, dir));49 return [...paths].filter((path) => existsSync(path));50}5152let timer: ReturnType<typeof setTimeout> | null = null;5354function refresh() {55 if (timer) clearTimeout(timer);56 timer = setTimeout(async () => {57 forget();58 site = await scan(spec);59 extra = null;60 }, 80);61}6263for (const path of watched()) watch(path, { recursive: statSync(path).isDirectory() }, refresh);6465/* SERVE */6667const pages = () => site.routes.filter((route) => route.kind !== "demos");6869const disk = (): [string, string][] => [70 ["/ui/", site.kit?.path ?? ""],71 ["/lib/", join(org, "lib")],72 ["/figures/", site.input("figures").path],73 ["/research/notes/", site.input("notes").path],74 ["/research/", site.input("research").path],75 ["/", site.input("public").path],76];7778const WIDGET = /^\/demos\/([a-z0-9-]+)\/widget\.js$/;7980const built = new Map<string, Output>();8182async function widget(name: string): Promise<Output | null> {83 const file = join(site.input("demos").path, name, "widget.jsx");84 if (!existsSync(file)) return null;85 const done = await Bun.build({ entrypoints: [file], root: org, define: { "process.env.NODE_ENV": '"development"' }, naming: { asset: "[name]-[hash].[ext]" } });86 if (!done.success) throw new Error(`dev: demos/${name}/widget.jsx failed to bundle\n${done.logs.join("\n")}`);87 for (const item of done.outputs) {88 const path = item.path.replace(/^\.\//, "");89 built.set(`/${path}`, { path, bytes: new Uint8Array(await item.arrayBuffer()) });90 }91 return built.get(`/demos/${name}/widget.js`) ?? null;92}9394async function seek(route: Route, want: string) {95 const outputs = await render(site, route, spec);96 return outputs.find((item) => item.path === want) ?? null;97}9899const TREE = "/demos/tree.json";100101async function serve(path: string): Promise<Response | null> {102 if (path === TREE) return send({ path: TREE.slice(1), bytes: JSON.stringify(demoTree(site)) });103 const want = path.endsWith("/") ? `${path.slice(1)}index.html` : path.slice(1);104 const embedded = path.match(WIDGET);105 if (embedded) {106 const hit = await widget(embedded[1]!);107 if (hit) return send(hit);108 }109 const held = built.get(path);110 if (held) return send(held);111 const route = pages().find((one) => one.route === path);112 if (route) {113 const hit = await seek(route, want);114 if (hit) return send(hit);115 }116 const back = rawOwner(path);117 const holder = back ? pages().find((one) => one.route === back) : null;118 if (holder) {119 const hit = await seek(holder, want);120 if (hit) return send(hit);121 }122 for (const [at, dir] of disk()) {123 if (!dir || !path.startsWith(at)) continue;124 const file = Bun.file(join(dir, path.slice(at.length)));125 if (await file.exists()) return new Response(file);126 }127 const owner = pages()128 .filter((one) => one.route.endsWith("/") && path.startsWith(one.route))129 .sort((a, b) => b.route.length - a.route.length)[0];130 if (owner && owner !== route) {131 const hit = await seek(owner, want);132 if (hit) return send(hit);133 }134 const hit = (await assets()).get(want);135 return hit ? send(hit) : null;136}137138async function lost() {139 const route = site.routes.find((one) => one.kind === "missing");140 if (!route) return new Response("not found", { status: 404 });141 const hit = await seek(route, "404.html");142 return hit ? send(hit, 404) : new Response("not found", { status: 404 });143}144145/* DEMOS */146147const home = site.input("demos").path;148const routes: Record<string, unknown> = {};149for (const file of site.input("demos").files) {150 if (!file.endsWith("/index.html")) continue;151 const name = file.slice(home.length + 1, -"/index.html".length);152 const bundle = (await import(file)).default;153 routes[name ? `/demos/${name}` : "/demos"] = bundle;154 routes[name ? `/demos/${name}/` : "/demos/"] = bundle;155}156157/* SERVER */158159const server = Bun.serve({160 port: Number(process.env.PORT ?? 3000),161 development: true,162 routes,163 async fetch(req) {164 const path = decodeURIComponent(new URL(req.url).pathname);165 if (!path.endsWith("/") && pages().some((one) => one.route === `${path}/`)) return Response.redirect(`${path}/`, 302);166 return (await serve(path)) ?? (await lost());167 },168});169170const count = counted();171console.log(`dev: ${site.routes.length} routes, ${count.papers} papers, ${count.research} research pages, ${count.blog} posts at ${server.url}`);