// The files an article was WRITTEN from, for reading beside it: a workspace's // drafts, the rendered drafts and briefs under polemics/, and the notes a // workspace keeps at its top level. Read-only -- this lists and serves, it // never writes a workspace. // // polemics/drafts/*.json the drafts (source of truth) // polemics/out/*.{md,html} what the drafts render to // polemics/*.md BRIEF.md, NOTES.md, PRIVACY-SWEEP.md, … // /{NOTES,BRIEF,LEADS,PLAN,PRIVACY-SWEEP}.md // /site/PLAN.md, /site/MERGE-PLAN.md import { readdir, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { REPORTS_ROOT } from "../paths.mjs"; export const TOP_LEVEL = ["NOTES.md", "BRIEF.md", "LEADS.md", "PLAN.md", "PRIVACY-SWEEP.md"]; const SITE_LEVEL = ["PLAN.md", "MERGE-PLAN.md"]; export const WORKSPACE_EXT = /\.(md|html|json)$/; const statFile = (p) => stat(/* turbopackIgnore: true */ p).then((s) => (s.isFile() ? s : null), () => null); /** * Every listed file of one workspace: `{ rel, abs, kind, bytes, mtimeMs }`, * `kind` one of "draft" | "out" | "doc". Sorted: drafts, docs, outputs; by name. * * @param {string} wsDir */ export async function workspaceFiles(wsDir) { const out = []; const add = async (rel, kind) => { const abs = path.join(/* turbopackIgnore: true */ wsDir, rel); const st = await statFile(abs); if (st) out.push({ rel, abs, kind, bytes: st.size, mtimeMs: Math.round(st.mtimeMs) }); }; const ls = (rel) => readdir(/* turbopackIgnore: true */ path.join(/* turbopackIgnore: true */ wsDir, rel)).catch(() => []); for (const f of await ls("polemics/drafts")) if (f.endsWith(".json")) await add(`polemics/drafts/${f}`, "draft"); for (const f of await ls("polemics/out")) if (/\.(md|html)$/.test(f)) await add(`polemics/out/${f}`, "out"); for (const f of await ls("polemics")) if (f.endsWith(".md")) await add(`polemics/${f}`, "doc"); for (const f of TOP_LEVEL) await add(f, "doc"); for (const f of SITE_LEVEL) await add(`site/${f}`, "doc"); const rank = { draft: 0, doc: 1, out: 2 }; return out.sort((a, b) => rank[a.kind] - rank[b.kind] || a.rel.localeCompare(b.rel)); } /** * A workspace file a client named, or null: `ws` must be a directory directly * under REPORTS_ROOT and `rel` one of the files workspaceFiles lists for it, * and its REAL path must stay inside the workspace. * * @param {string} ws the workspace's name under REPORTS_ROOT * @param {string} rel * @param {{ reportsRoot?: string }} [opts] */ export async function workspaceFile(ws, rel, { reportsRoot = REPORTS_ROOT } = {}) { if (typeof ws !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(ws) || ws === "..") return null; if (typeof rel !== "string" || rel.includes("\0") || rel.split("/").some((s) => s === ".." || s === "" || s === ".")) return null; const dir = path.join(/* turbopackIgnore: true */ reportsRoot, ws); const listed = (await workspaceFiles(dir)).find((f) => f.rel === rel); if (!listed) return null; const [realDir, realAbs] = await Promise.all([ realpath(/* turbopackIgnore: true */ dir).catch(() => null), realpath(/* turbopackIgnore: true */ listed.abs).catch(() => null), ]); if (!realDir || !realAbs || !realAbs.startsWith(realDir + path.sep)) return null; return { ...listed, real: realAbs }; }