import { Router, type Router as ExpressRouter } from "express"; import fs from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { PROJECTS_ROOT } from "../config.js"; import { projectDir, projectNameSchema } from "../paths.js"; import { gitInit } from "../git.js"; export const projectsRouter: ExpressRouter = Router(); const MAX_TREE_DEPTH = 6; const MAX_TREE_ENTRIES = 2000; projectsRouter.get("/", async (_req, res) => { try { await fs.mkdir(PROJECTS_ROOT, { recursive: true }); const entries = await fs.readdir(PROJECTS_ROOT, { withFileTypes: true }); const projects = entries .filter((e) => e.isDirectory() && !e.name.startsWith(".")) .map((e) => e.name) .sort(); res.json({ projects }); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); projectsRouter.post("/:project", async (req, res) => { const parsed = projectNameSchema.safeParse(req.params.project); if (!parsed.success) { res.status(400).json({ error: parsed.error.issues }); return; } const name = parsed.data; const dir = path.join(PROJECTS_ROOT, name); try { await fs.mkdir(dir, { recursive: true }); const main = path.join(dir, "main.typ"); try { await fs.access(main); } catch { await fs.writeFile( main, // The Typst heading `=` must be followed by a space, then the title. `= ${name}\n\nHello from a fresh project.\n`, "utf8", ); } await gitInit(name).catch((e) => console.warn(`[projects] git init failed for "${name}":`, e.message), ); res.status(201).json({ project: name }); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); projectsRouter.get("/:project", async (req, res) => { const parsed = projectNameSchema.safeParse(req.params.project); if (!parsed.success) { res.status(400).json({ error: parsed.error.issues }); return; } const dir = projectDir(parsed.data); try { await fs.access(dir); } catch { res.status(404).json({ error: "project not found" }); return; } res.json({ project: parsed.data, uri: pathToFileURL(dir).href }); }); projectsRouter.get("/:project/tree", async (req, res) => { const parsed = projectNameSchema.safeParse(req.params.project); if (!parsed.success) { res.status(400).json({ error: parsed.error.issues }); return; } const dir = projectDir(parsed.data); try { await fs.access(dir); } catch { res.status(404).json({ error: "project not found" }); return; } const out: { path: string; mtime: number }[] = []; const budget = { left: MAX_TREE_ENTRIES }; async function walk(rel: string, depth: number): Promise { if (depth > MAX_TREE_DEPTH || budget.left <= 0) return; const abs = path.join(dir, rel); const entries = await fs.readdir(abs, { withFileTypes: true }); for (const entry of entries) { if (budget.left <= 0) return; if (entry.name.startsWith(".") || entry.name === "node_modules") continue; const childRel = rel ? `${rel}/${entry.name}` : entry.name; const childAbs = path.join(abs, entry.name); const stat = await fs.stat(childAbs); if (entry.isDirectory()) { await walk(childRel, depth + 1); } else if (entry.isFile() && entry.name.endsWith(".typ")) { out.push({ path: childRel, mtime: stat.mtimeMs }); budget.left -= 1; } } } await walk("", 0); res.json({ project: parsed.data, files: out }); });