From 32abe42dc105c290c9f1435d7f6e64bdab55827a Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Thu, 20 Aug 2026 13:37:30 -0400 Subject: [PATCH] feat(scripts): add sync script for leetcode solutions --- scripts/sync.ts | 395 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100755 scripts/sync.ts diff --git a/scripts/sync.ts b/scripts/sync.ts new file mode 100755 index 0000000..17c71e0 --- /dev/null +++ b/scripts/sync.ts @@ -0,0 +1,395 @@ +#!/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 { readdir, 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:", + ` 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(); + +// Existing docs pages, keyed by leetcode number from the frontmatter title. +const pages = new Map<number, string>(); // num -> filename +for (const f of (await readdir(DOCS)).filter((f) => f.endsWith(".mdx")).sort()) { + const text = await Bun.file(join(DOCS, f)).text(); + const t = text.match(/^title: '(\d+)\./m); + if (t) pages.set(Number(t[1]), f); +} + +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})`; + const name = `${sol.num}-${sol.slug}.mdx`; // prefix = leetcode number (numeric sidebar order, stripped from the URL) + const existing = pages.get(sol.num); + + 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 = page.slice(0, at) + solutionSection(sol); + const actions: string[] = []; + if (existing !== name) { + await rename(join(DOCS, existing), join(DOCS, name)); + actions.push("renamed"); + } + if (next !== page) { + await Bun.write(join(DOCS, name), next); + actions.push("updated (solution)"); + } + 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}`);