#!/usr/bin/env bun /** * Sync work/ leetcode solutions into docs/solutions/ pages. * * - New problems get a full page (frontmatter, badge, warning, examples, * constraints, solution) in the gold-standard format. * - Already-ported pages only get their `## Solution` section regenerated, * 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. */ import { mkdir, rename } from "node:fs/promises"; import { basename, join } from "node:path"; const ROOT = join(import.meta.dir, ".."); const WORK = join(ROOT, "work"); const DOCS = join(ROOT, "docs", "solutions"); 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 ───────────────────────────────────────────── const TOPIC_ALIASES: Record<string, string> = { "Hash-Based Lookup": "Hash Table", }; async function readmeTopics(): Promise<Map<number, string>> { const md = await Bun.file(join(ROOT, "README.md")).text(); const map = new Map<number, string>(); let topic = ""; for (const line of md.split("\n")) { const t = line.match(/<strong>\d+\.\s*([^<]+)<\/strong>/); if (t) topic = TOPIC_ALIASES[t[1]!.trim()] ?? t[1]!.trim(); if (!topic) continue; const n = line.match(/<td align="center">(\d+)<\/td>/); if (n) map.set(Number(n[1]), topic); } return map; } // ── 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 renderPage(sol: Solution, topic: string): 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}'`, "---", "", `<Badge variant="accent">${topic}</Badge>`, "", ); 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/${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}`); } // 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); for (const sol of ordered) { const langs = Object.keys(sol.code).sort().join("+"); const workRef = `${sol.num}.${sol.slug} (${langs})`; // (category) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL. const group = `(${sol.category.toLowerCase().replaceAll(" ", "-")})`; 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 next = ensureLabel(page.slice(0, at) + solutionSection(sol), sol.header); const actions: string[] = []; if (existing !== name) { await rename(join(DOCS, existing), join(DOCS, name)); 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 { const topic = topics.get(sol.num) ?? sol.category; await Bun.write(join(DOCS, name), renderPage(sol, topic)); report.push([workRef, name, "created"]); } } 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}`);