#!/usr/bin/env bun /** * Sync work/1 leetcode solutions into apps/docs/content/ pages. * * - New problems get a full page (frontmatter, badge, warning, examples, * constraints, solution) in the gold-standard format. * - Already-ported pages only get their script-owned parts regenerated — the * `` line and the `## Solution` section — so hand-curated prose is * never touched. * - When a problem is solved in both JavaScript and Python the solution * renders as a (Python first); single-language solutions * render as a plain fence. * * Two classifications, two homes. The sidebar group is the campaign TOPIC * from the README table (`tree-dfs/`, ordered by topic number via a * script-owned `meta.ts`); the page badge is the data-structure CATEGORY the * file sits under in `work/1///`. A problem outside the * plan (warm-ups) has no topic, so it groups under its category instead. * * Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`. * The phase folder is a Blume `(group)` — a sidebar section with its own * script-owned `meta.ts`, adding no route segment — while the topic folder is * a real segment (`/tree-dfs/invert-binary-tree`), so a hand-written * `index.mdx` in it is the topic's landing page at `/tree-dfs` and Blume links * the sidebar group row to it. Sync never writes or moves an `index.mdx`, and * never removes a folder that still holds one. An unplanned category group has * no phase and sits at the content root. * * Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves * of problems whose page already exists (see work.ts), and a fresh scaffold * there would otherwise wipe the page's `## Solution`. * * Every page also gets a short link: `/` → `//` (so * `/leetcode/217` opens Contains Duplicate). The rules live in the generated * `apps/docs/redirects.ts`, which `blume.config.ts` imports — Blume takes exact * root-relative paths and prepends the deployment base itself. */ import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; const ROOT = join(import.meta.dir, "..", ".."); const WORK = join(ROOT, "work", "1"); const DOCS = join(ROOT, "apps", "docs", "content"); const REDIRECTS = join(ROOT, "apps", "docs", "redirects.ts"); type Lang = "js" | "py"; interface Example { input: string; output: string; explanation: string[]; // [] = none; 1 entry = inline; >1 = bullet list } interface Header { num: number; title: string; difficulty: string; statement: string[]; // unwrapped paragraphs examples: Example[]; constraints: string[]; followUp: string; } interface Solution { num: number; slug: string; category: string; header: Header; code: Partial>; } // ── work/ parsing ──────────────────────────────────────────────── /** Split a source file into (header comment text, code). */ function splitSource(src: string, lang: Lang): { header: string; code: string } { let header: string, code: string; if (lang === "js") { const end = src.indexOf("*/"); if (!src.trimStart().startsWith("/*") || end === -1) throw new Error("missing /* header */"); header = src .slice(src.indexOf("/*") + 2, end) .split("\n") .map((l) => l.replace(/^\s*\* ?/, "")) .join("\n"); code = src.slice(end + 2); } else { const open = src.indexOf('"""'); const close = src.indexOf('"""', open + 3); if (open === -1 || close === -1) throw new Error('missing """ header """'); header = src.slice(open + 3, close); code = src.slice(close + 3); } return { header, code: code.replace(/^\s*\n/, "").trimEnd() }; } /** Unwrap hard-wrapped lines into logical paragraphs / bullets. */ function paragraphs(lines: string[]): string[] { const out: string[] = []; let cur = ""; const flush = () => { if (cur) out.push(cur); cur = ""; }; for (const raw of lines) { const line = raw.replace(/\t/g, " ").trimEnd(); const text = line.trim(); if (!text || /^[─-]{3,}$/.test(text)) { flush(); continue; } if (/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(text)) { flush(); cur = text; } else { cur = cur ? `${cur} ${text}` : text; } } flush(); return out; } function parseHeader(header: string): Header { const lines = header.split("\n"); const titleLine = lines.find((l) => /^\s*\d+\.\s/.test(l))?.trim(); if (!titleLine) throw new Error("missing '. ' line"); const num = Number.parseInt(titleLine, 10); const title = titleLine.replace(/^\d+\.\s*/, ""); const difficulty = lines.find((l) => l.trim().startsWith("Difficulty:"))?.split(":")[1]?.trim() ?? ""; // Body: everything after the ───── separator. const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim())); const paras = paragraphs(lines.slice(sep + 1)); const statement: string[] = []; const examples: Example[] = []; const constraints: string[] = []; let followUp = ""; let section: "statement" | "example" | "constraints" = "statement"; let ex: Example | null = null; for (const p of paras) { if (/^Example \d+:?$/.test(p)) { if (ex) examples.push(ex); ex = { input: "", output: "", explanation: [] }; section = "example"; continue; } if (/^Constraints:$/.test(p)) { if (ex) examples.push(ex); ex = null; section = "constraints"; continue; } const fu = p.match(/^Follow[- ]?ups?:\s*(.*)$/i); if (fu) { followUp = fu[1]!; continue; } if (section === "statement") statement.push(p.replace(/^• /, "")); else if (section === "constraints") constraints.push(p.replace(/^• /, "")); else if (ex) { // Example paragraphs: Input/Output/Explanation, wrapped arbitrarily. // A paragraph may fuse "Input: … Output: …" only across real lines, // but source always keeps them on separate wrapped paragraphs. if (p.startsWith("Input:")) ex.input = p.slice(6).trim(); else if (p.startsWith("Output:")) ex.output = p.slice(7).trim(); else if (p.startsWith("Explanation:")) { const rest = p.slice(12).trim(); if (rest) ex.explanation.push(rest); } else if (p.startsWith("- ")) ex.explanation.push(p.slice(2)); else if (ex.explanation.length) ex.explanation[ex.explanation.length - 1] += ` ${p}`; else ex.explanation.push(p); } } if (ex) examples.push(ex); return { num, title, difficulty, statement, examples, constraints, followUp }; } // ── README topic map ───────────────────────────────────────────── /** A campaign phase: `## Phase N — Name · Weeks …` in the README. */ interface Phase { order: number; name: string; } /** A campaign topic: `<strong>NN. Name</strong>` in the README table. */ interface Topic { order?: number; // README topic number; absent for an unplanned problem's category group name: string; phase?: Phase; // absent for an unplanned category group } /** README phase names → the short labels the overview page uses. */ const PHASE_ALIASES: Record<string, string> = { "Nodal & Grid Structures": "Nodal & Grid", "Hierarchical Structures": "Hierarchical", "Relational Structures": "Relational", "Decision-Space Exploration": "Decision Space", }; const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]; const TOPIC_ALIASES: Record<string, string> = { "Hash-Based Lookup": "Hash Table", }; /** * Sidebar icon per group, keyed by the (post-alias) topic name — the same key an * unplanned problem's category group uses, so `Stack` the category and `Stack` * the topic share one row. Lucide names; Blume resolves them at build time and * reports a diagnostic for a typo, but an unmapped group is caught here first. */ const TOPIC_ICONS: Record<string, string> = { // README topics, in curriculum order "Hash Table": "hash", "Prefix Sum": "sigma", "Two Pointers": "arrow-left-right", "Sliding Window": "app-window", "Binary Search": "search", Stack: "layers", "Monotonic Stack": "trending-up", "Linked List": "link", "Hybrid Structures": "combine", "Matrix Index Math": "grid-3x3", "Tree DFS": "folder-tree", "Tree BFS": "network", "Binary Search Tree": "git-fork", "Heap / Priority Queue": "triangle", Trie: "spell-check", "Graph DFS": "waypoints", "Graph BFS": "radar", "Topological Sort": "workflow", "Union-Find": "merge", Backtracking: "undo-2", "1-D Dynamic Programming": "columns-3", "Multi-D / Grid DP": "table", Greedy: "coins", Intervals: "calendar-range", // work/1 categories that can surface as an unplanned group Array: "brackets", String: "type", Tree: "network", "Depth-First Search": "folder-tree", }; /** LC number -> topic. A problem listed under two topics keeps the first. */ async function readmeTopics(): Promise<Map<number, Topic>> { const md = await Bun.file(join(ROOT, "README.md")).text(); const map = new Map<number, Topic>(); let phase: Phase | undefined; let topic: Topic | null = null; for (const line of md.split("\n")) { const p = line.match(/^## Phase ([IVX]+) — (.+?) · /); if (p) { const name = p[2]!.replaceAll("&", "&"); phase = { order: ROMAN.indexOf(p[1]!) + 1, name: PHASE_ALIASES[name] ?? name }; } const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/); if (t) topic = { order: Number(t[1]), name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim(), phase }; if (!topic) continue; const n = line.match(/<td align="center">(\d+)<\/td>/); if (n && !map.has(Number(n[1]))) map.set(Number(n[1]), topic); } return map; } /** * Folder slug for a topic or phase name. Blume reads a leading `<digits>-` on a * folder as a sidebar-order prefix and strips it from the route (`1-d-…` → * `/d-…`), so a slug that would start that way keeps the digits glued to the * next token (`1d-dynamic-programming`). */ function slugify(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .replace(/^(\d+)-/, "$1"); } /** * Content folder for a topic, relative to `DOCS`: `(0N-<phase>)/<topic>` for a * planned topic (the phase is a `(group)`, so the route is still `/<topic>`), * bare `<topic>` for an unplanned category group. */ function topicDir(topic: Topic): string { const slug = slugify(topic.name); return topic.phase ? `${phaseDir(topic.phase)}/${slug}` : slug; } function phaseDir(phase: Phase): string { return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`; } /** Blume `(group)` meta for a phase: the overview page's heading, in phase order. */ function phaseMeta(phase: Phase): string { return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(`Phase ${phase.order}: ${phase.name}`)}, order: ${phase.order} });\n`; } /** * Blume folder meta for a topic: exact label (the folder slug loses `/` and * case), curriculum order, the sidebar icon, and `display: "group"` so the * topic collapses under its phase heading. An unplanned category group * (warm-ups) has no README number and sorts at 0, ahead of phase 1, so the * sidebar opens on the basics. */ function groupMeta(topic: Topic): string { const icon = TOPIC_ICONS[topic.name]; if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`); const fields = [`title: ${JSON.stringify(topic.name)}`, `order: ${topic.order ?? 0}`]; if (icon) fields.push(`icon: ${JSON.stringify(icon)}`); fields.push(`display: "group"`); return `import { defineMeta } from "blume";\n\nexport default defineMeta({ ${fields.join(", ")} });\n`; } // ── inline-code formatting heuristics ──────────────────────────── /** Identifiers worth backticking in prose, harvested from the code. */ function identifiers(sol: Solution): Set<string> { const ids = new Set<string>(["n", "m", "k"]); for (const code of Object.values(sol.code)) { for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g)) ids.add(m[1]!); for (const m of code.matchAll(/def \w+\(self,?\s*([^)]*)\)/g)) for (const arg of m[1]!.split(",")) if (arg.trim()) ids.add(arg.split(":")[0]!.trim()); for (const m of code.matchAll(/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g)) for (const arg of m[2]!.split(",")) if (arg.trim()) ids.add(arg.trim()); } return ids; } function formatConstraint(c: string, ids: Set<string>): string { if (/<=|>=|==|!=|<|>/.test(c)) return `\`${c}\``; // Prose constraint: backtick identifier-ish tokens only. return c .split(" ") .map((word) => { const m = word.match(/^([\w.]+(?:\[[^\]]*\])?)([.,;:]?)$/); if (!m) return word; const [, tok, punct] = m; if (/\[[^\]]*\]/.test(tok!) || ids.has(tok!)) return `\`${tok}\`${punct}`; return word; }) .join(" "); } /** Wrap value literals / expressions in an explanation sentence. */ function backtickify(text: string, ids: Set<string>): string { const words = text.split(" "); type Tok = { pre: string; core: string; post: string; codey: boolean }; const toks: Tok[] = words.map((w) => { const m = w.match(/^([("']*)(.*?)([)"'.,;:!?]*)$/)!; const core = m[2]!; const codey = /^-?\d+(\.\d+)?([+\-*/]-?\d+(\.\d+)?)*$/.test(core) || // number / compact arithmetic /^\[[^\]]*\]$/.test(core) || // array literal /^[a-zA-Z_]\w*\[[^\]]*\]$/.test(core) || // indexed identifier /^[+\-*/=%]$/.test(core) || // operator ids.has(core); return { pre: m[1]!, core, post: m[3]!, codey }; }); const out: string[] = []; let i = 0; while (i < toks.length) { if (!toks[i]!.codey || /^[+\-*/=%]$/.test(toks[i]!.core)) { out.push(words[i]!); // prose, or an operator with no codey run to its left i++; continue; } // Extend a run of codey tokens; only unbroken by punctuation. let j = i; while ( j + 1 < toks.length && toks[j + 1]!.codey && !toks[j]!.post && // punctuation after a token ends the run !toks[j + 1]!.pre.includes('"') ) j++; // Trim trailing operators from the run (e.g. "5 and" keeps "and" out anyway). while (j > i && /^[+\-*/=%]$/.test(toks[j]!.core)) j--; const span = toks .slice(i, j + 1) .map((tok, idx, arr) => (idx === arr.length - 1 ? `${tok.pre}${tok.core}` : `${tok.pre}${tok.core}${tok.post}`)) .join(" "); out.push(`\`${span}\`${toks[j]!.post}`); i = j + 1; } return out.join(" "); } // ── page rendering ─────────────────────────────────────────────── const FENCE: Record<Lang, { info: string; label: string }> = { py: { info: "py", label: "Python" }, js: { info: "js", label: "JavaScript" }, }; function solutionSection(sol: Solution): string { const langs = (["py", "js"] as const).filter((l) => sol.code[l]); const fence = (l: Lang, titled: boolean) => `\`\`\`${FENCE[l].info}${titled ? ` ${FENCE[l].label}` : ""}\n${sol.code[l]}\n\`\`\``; if (langs.length === 1) return `## Solution\n\n${fence(langs[0]!, false)}\n`; return `## Solution\n\n<CodeGroup>\n\n${langs.map((l) => fence(l, true)).join("\n\n")}\n\n</CodeGroup>\n`; } function warningText(h: Header): string { if (h.followUp) return h.followUp; return ( h.statement .slice(1) .find((p) => /^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(p)) ?? "" ); } function badgeLine(sol: Solution): string { return `<Badge variant="accent">${sol.category}</Badge>`; } function renderPage(sol: Solution): string { const h = sol.header; const ids = identifiers(sol); const description = h.statement[0]?.replace(/\.\s*$/, "") ?? ""; const parts: string[] = []; parts.push( "---", `title: '${h.num}. ${h.title.replaceAll("'", "''")}'`, /[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`, "sidebar:", ` label: '${h.title.replaceAll("'", "''")}'`, ` badge: '${h.difficulty}'`, "---", "", badgeLine(sol), "", ); const warning = warningText(h); if (warning) parts.push("::::warning", warning, "::::", ""); h.examples.forEach((ex, i) => { parts.push(`### Example ${i + 1}:`); parts.push(`- Input: \`${ex.input}\``); parts.push(`- Output: \`${ex.output}\``); if (ex.explanation.length === 1) parts.push(`- Explanation: ${backtickify(ex.explanation[0]!, ids)}`); else if (ex.explanation.length > 1) { parts.push("- Explanation:"); for (const item of ex.explanation) parts.push(` - ${backtickify(item, ids)}`); } parts.push(""); }); parts.push("### Constraints:", ""); for (const c of h.constraints) parts.push(`- ${formatConstraint(c, ids)}`); parts.push("", solutionSection(sol)); return parts.join("\n"); } // ── main ───────────────────────────────────────────────────────── const solutions = new Map<number, Solution>(); for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) { const lang = rel.endsWith(".py") ? "py" : ("js" as Lang); const file = basename(rel); const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/); if (!m) { console.warn(`skip (unrecognized name): work/1/${rel}`); continue; } const num = Number(m[1]); const slug = m[2]!; const { header, code } = splitSource(await Bun.file(join(WORK, rel)).text(), lang); const parsed = parseHeader(header); const existing = solutions.get(num); if (existing) { existing.code[lang] = code; if (lang === "js") existing.header = parsed; // js header wins when both exist } else { solutions.set(num, { num, slug, category: rel.split("/")[1] ?? "", header: parsed, code: { [lang]: code }, }); } } const topics = await readmeTopics(); // An unplanned problem whose work/ category is also a README topic name (a // `Hash Table` warm-up) files under that topic, in its phase, not a second // root-level `hash-table/`. const topicByName = new Map<string, Topic>(); for (const topic of topics.values()) topicByName.set(topic.name, topic); /** Insert a number-free `sidebar.label` into existing frontmatter when missing. */ function ensureLabel(page: string, h: Header): string { const fm = page.match(/^---\n[\s\S]*?\n---/); if (!fm || /^ {2}label: /m.test(fm[0])) return page; const label = ` label: '${h.title.replaceAll("'", "''")}'`; return page.replace(/^sidebar:$/m, `sidebar:\n${label}`); } /** * Offset just past the script-owned `## Solution` section: the next top-level * heading that is not inside a code fence, or end of file. Sections a human * appends after the solution (`## Explanation`, …) survive regeneration. */ function solutionEnd(page: string, from: number): number { const lines = page.slice(from).split("\n"); let offset = from + lines[0]!.length + 1; // past the '## Solution' line itself let fenced = false; for (let i = 1; i < lines.length; i++) { const line = lines[i]!; if (line.startsWith("```")) fenced = !fenced; else if (!fenced && line.startsWith("## ")) return offset; offset += line.length + 1; } return page.length; } // Existing docs pages, keyed by leetcode number from the frontmatter title. const pages = new Map<number, string>(); // num -> path relative to DOCS for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).sort()) { const text = await Bun.file(join(DOCS, rel)).text(); const t = text.match(/^title: '(\d+)\./m); if (t) pages.set(Number(t[1]), rel); } const report: [string, string, string][] = []; const ordered = [...solutions.values()].sort((a, b) => a.num - b.num); const groups = new Map<string, Topic>(); // topic folder -> topic, for meta.ts const phases = new Map<string, Phase>(); // phase folder -> phase, for meta.ts const vacated = new Set<string>(); // folders a page moved out of; removed below if empty const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order for (const sol of ordered) { const langs = Object.keys(sol.code).sort().join("+"); const workRef = `${sol.num}.${sol.slug} (${langs})`; // (0N-<phase>)/<topic>/ folder (the topic is a real route segment, so its // index.mdx is the landing page) + leetcode-number prefix: numeric sidebar // order, prefix stripped from the URL. const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category }; const dir = topicDir(topic); groups.set(dir, topic); if (topic.phase) phases.set(phaseDir(topic.phase), topic.phase); const name = `${dir}/${sol.num}-${sol.slug}.mdx`; const existing = pages.get(sol.num); await mkdir(join(DOCS, dir), { recursive: true }); if (existing) { const page = await Bun.file(join(DOCS, existing)).text(); const at = page.indexOf("## Solution"); if (at === -1) { report.push([workRef, existing, "ERROR: no '## Solution' heading"]); continue; } const tail = page.slice(solutionEnd(page, at)); const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : ""); const next = ensureLabel(body, sol.header).replace(/^<Badge variant="accent">.*<\/Badge>$/m, badgeLine(sol)); const actions: string[] = []; if (existing !== name) { await rename(join(DOCS, existing), join(DOCS, name)); vacated.add(dirname(existing)); actions.push("moved"); } if (next !== page) { await Bun.write(join(DOCS, name), next); actions.push("updated"); } report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]); } else { await Bun.write(join(DOCS, name), renderPage(sol)); report.push([workRef, name, "created"]); } // The route drops the phase `(group)` and the page's number prefix. redirects.push(` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`); } // Script-owned short links, one per page; imported by blume.config.ts. { const module = [ "// Generated by `bun run sync` (apps/cli/sync.ts) — do not edit.", "// `/leetcode/<num>` → that problem's page; exact paths, base added by Blume.", "export default [", ...redirects, "];", "", ].join("\n"); if (!(await Bun.file(REDIRECTS).exists()) || (await Bun.file(REDIRECTS).text()) !== module) { await Bun.write(REDIRECTS, module); report.push([`${redirects.length} redirects`, "apps/docs/redirects.ts", "written"]); } } // Script-owned folder metas — one per live phase and topic folder — rewritten // only when they differ. const metas: [label: string, dir: string, meta: string][] = [ ...[...phases].map(([dir, phase]): [string, string, string] => [`phase ${phase.order}`, dir, phaseMeta(phase)]), ...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]), ]; for (const [label, dir, meta] of metas) { const path = join(DOCS, dir, "meta.ts"); if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta) continue; await Bun.write(path, meta); report.push([label, `${dir}/meta.ts`, "written"]); } // A folder every page left behind is dead: drop its meta and the folder itself, // then the phase folder above it if that emptied too. A folder that still holds // anything else (a hand-written index.mdx, another topic) stops the climb. for (const start of vacated) { for (let dir = start; dir !== "."; dir = dirname(dir)) { if (groups.has(dir) || phases.has(dir)) break; let left: string[]; try { left = await readdir(join(DOCS, dir)); } catch { break; // already removed via a sibling's climb } if (left.some((f) => f !== "meta.ts")) break; if (left.length) await unlink(join(DOCS, dir, "meta.ts")); await rmdir(join(DOCS, dir)); report.push(["", dir, "removed (empty)"]); } } const w0 = Math.max(...report.map((r) => r[0].length), 9); const w1 = Math.max(...report.map((r) => r[1].length), 9); console.log(`${"work file".padEnd(w0)} ${"docs page".padEnd(w1)} status`); console.log(`${"─".repeat(w0)} ${"─".repeat(w1)} ${"─".repeat(20)}`); for (const [a, b, c] of report) console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);