/** * SOP (Standard Operating Procedure) loader. * * Reads markdown files from `docs/sops/` at request time on the server. * The markdown is the source of truth; the `/sops` route in the app * just renders it. Edit the .md files, push, redeploy — the in-app * page updates automatically. * * Files in scope: * docs/sops/.md * where is the kebab-case role name (`quote-operator`, etc). * `docs/sops/README.md` is intentionally excluded — it lives at the * repo level and is rendered in the index page directly. */ import { promises as fs } from "node:fs"; import path from "node:path"; const SOPS_DIR = path.join(process.cwd(), "docs", "sops"); const README_FILENAME = "README.md"; export type SopRole = { /** URL slug, e.g. "quote-operator". */ slug: string; /** Display title from the first `# heading` in the file. */ title: string; /** One-line "Owns: ..." summary from the file's metadata block. */ owns: string; /** First paragraph after the title — short blurb for the index card. */ blurb: string; /** Ordered list of the 4 anatomy section headings, in file order. */ sections: string[]; }; export type SopDoc = { role: SopRole; /** Raw markdown body, sans the front-matter block we strip out. */ body: string; }; /** Read every role SOP file in `docs/sops/` (excludes README). */ export async function listSops(): Promise { let entries: string[]; try { entries = await fs.readdir(SOPS_DIR); } catch (err) { // If the folder doesn't exist (fresh clone before any SOPs are // written), surface a clean empty list rather than crashing the // page. if ((err as NodeJS.ErrnoException).code === "ENOENT") return []; throw err; } const mdFiles = entries .filter((name) => name.endsWith(".md") && name !== README_FILENAME) .sort(); const roles = await Promise.all( mdFiles.map(async (filename) => { const slug = filename.replace(/\.md$/, ""); const raw = await fs.readFile(path.join(SOPS_DIR, filename), "utf8"); return parseRole(slug, raw); }), ); return roles; } /** Read a single SOP by slug. Returns null if the file is missing. */ export async function getSop(slug: string): Promise { // Defensive: slugs only. No path traversal via ".." etc. if (!/^[a-z0-9-]+$/.test(slug)) return null; const filePath = path.join(SOPS_DIR, `${slug}.md`); let raw: string; try { raw = await fs.readFile(filePath, "utf8"); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; throw err; } const role = parseRole(slug, raw); return { role, body: extractBody(raw) }; } /** Read every SOP as a full SopDoc (used by the combined print view). */ export async function getAllSops(): Promise { const roles = await listSops(); const docs = await Promise.all(roles.map((r) => getSop(r.slug))); return docs.filter((d): d is SopDoc => d !== null); } /** Read the repo-level README that describes the SOPs as a set. */ export async function getSopsReadme(): Promise { try { return await fs.readFile(path.join(SOPS_DIR, README_FILENAME), "utf8"); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; throw err; } } // --- internals ------------------------------------------------------------ /** * Parse a role .md file into the metadata used by the index page and * the detail page header. * * Expected shape (written by the maintainer; no front-matter to keep * the source readable in plain editors): * * # — SOP * * **Owns:** <one line>. * **Tools:** <comma-separated list>. * **Handoffs:** <bulleted list>. * * <one paragraph blurb> * * --- * * ## 1. <Section A> * ... * ## 2. <Section B> * ... */ function parseRole(slug: string, raw: string): SopRole { const lines = raw.split("\n"); // 1. Title — first H1. const titleLine = lines.find((l) => l.startsWith("# ")); const title = titleLine ? titleLine.replace(/^#\s+/, "").replace(/\s+—\s*SOP\s*$/, "").trim() : slug; // 2. "Owns:" line — first **Owns:** ... line. const ownsLine = lines.find((l) => l.startsWith("**Owns:**")); const owns = ownsLine ? ownsLine.replace(/^\*\*Owns:\*\*\s*/, "").replace(/\.?\s*$/, "").trim() : ""; // 3. Blurb — first non-empty paragraph after the metadata block, // before the first `---` horizontal rule. const hrIndex = lines.findIndex((l) => l.trim() === "---"); const metaEnd = hrIndex === -1 ? lines.length : hrIndex; const metaSlice = lines.slice(0, metaEnd); // Skip the title line and any "**Foo:** bar" metadata lines. const blurbLines: string[] = []; let pastMeta = false; for (const line of metaSlice) { if (!pastMeta) { if (line.startsWith("# ")) continue; if (line.startsWith("**") && line.includes(":**")) continue; if (line.trim() === "") continue; pastMeta = true; } if (line.trim() === "") { if (blurbLines.length > 0) break; continue; } blurbLines.push(line); } const blurb = blurbLines.join(" ").trim(); // 4. Section headings — every "## N. <name>" or "## <name>" line. const sections = lines .filter((l) => /^##\s+/.test(l)) .map((l) => l.replace(/^##\s+/, "").trim()); return { slug, title, owns, blurb, sections }; } /** * Strip the front-matter block (title + **Owns/Tools/Handoffs** + * blurb + leading `---`) so `react-markdown` doesn't render it twice. * Keep the body content (sections 1-4 onward) untouched. */ function extractBody(raw: string): string { const lines = raw.split("\n"); const hrIndices = lines .map((l, i) => ({ l, i })) .filter(({ l }) => l.trim() === "---") .map(({ i }) => i); // The first `---` separates the front matter from the body. If there // isn't one, return the whole document (caller renders the title // header from the role metadata instead). if (hrIndices.length === 0) return raw; const bodyStart = hrIndices[0] + 1; return lines.slice(bodyStart).join("\n").trimStart(); }