mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
feat(scripts): add sync script for leetcode solutions
This commit is contained in:
Executable
+395
@@ -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 <CodeGroup> (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<Record<Lang, string>>;
|
||||
}
|
||||
|
||||
// ── 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 '<num>. <title>' 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}`);
|
||||
Reference in New Issue
Block a user