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}`);