#!/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. * * 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`. */ 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"); 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 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; } const TOPIC_ALIASES: Record<string, string> = { "Hash-Based Lookup": "Hash Table", }; /** 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 topic: Topic | null = null; for (const line of md.split("\n")) { const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/); if (t) topic = { order: Number(t[1]), name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim() }; 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; } /** `(group)` folder name: route-transparent in Blume, so only the sidebar sees it. */ function groupFolder(topic: Topic): string { return `(${topic.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")})`; } /** Blume folder meta: exact topic label (the folder slug loses `/` and case) and curriculum order. */ function groupMeta(topic: Topic): string { const order = topic.order === undefined ? "" : `, order: ${topic.order}`; return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(topic.name)}${order} });\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(); /** 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>(); // folder -> topic, for meta.ts const vacated = new Set<string>(); // folders a page moved out of; removed below if empty for (const sol of ordered) { const langs = Object.keys(sol.code).sort().join("+"); const workRef = `${sol.num}.${sol.slug} (${langs})`; // (topic) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL. // An unplanned problem's category may share a folder with a numbered topic // (`Hash Table` warm-ups under topic 1); the numbered one owns the meta. const topic = topics.get(sol.num) ?? { name: sol.category }; const group = groupFolder(topic); if (groups.get(group)?.order === undefined) groups.set(group, topic); const name = `${group}/${sol.num}-${sol.slug}.mdx`; const existing = pages.get(sol.num); await mkdir(join(DOCS, group), { 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"]); } } // Script-owned folder meta: one per live group, rewritten only when it differs. for (const [group, topic] of groups) { const path = join(DOCS, group, "meta.ts"); const meta = groupMeta(topic); if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta) continue; await Bun.write(path, meta); report.push([`topic ${topic.order ?? "-"}`, `${group}/meta.ts`, "written"]); } // A group every page left behind is dead: drop its meta and the folder itself. for (const dir of vacated) { if (groups.has(dir)) continue; const left = await readdir(join(DOCS, dir)); if (left.some((f) => f !== "meta.ts")) continue; 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}`);