#!/usr/bin/env bun /** * Sync work/1 leetcode solutions into apps/docs/content/ pages. * * - New problems get a full page (frontmatter, warning, examples, * constraints, solution) in the gold-standard format. * - Already-ported pages only get their script-owned parts regenerated — the * frontmatter `icon:` (`PAGE_ICON`) 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. * - Every page carries LeetCode's own topic tags as frontmatter `topics:` * (`{ name, slug }` per tag, LeetCode's order), fetched from the GraphQL * `topicTags` once — when the page is created, or on any later run that * finds the key missing — and never refreshed after, so a sync with nothing * new makes no request. Delete the key to refetch it. The page itself * renders no badges: the docs MDX `wrapper` * (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the * `video:` key `videos.ts` writes into the badge row under the description. * - Every planned page (a problem the README plan lists) carries its GitHub * problem issue as frontmatter `issue:` (the URL of the `problem`-labelled * issue titled `LC · …`), rendered by the same wrapper as a GitHub * badge after the others; an unplanned warm-up has no catalog issue and no * key. Like `topics:` it is filled once — on create, or on any run that * finds it missing — and the issue list is fetched only on such a run * (never on a sync with nothing new); offline, or with no `gh` auth, the * page is written without it and the next sync retries. Delete the key to * refetch it. * - Every tag a page carries that no campaign topic teaches (not a key of * `apps/docs/topic-landings.ts`) belongs in the Appendix glossary, * `apps/docs/appendix.ts`, which is hand-written: sync only checks it, * warning about a carried tag with no term (its badge falls back to * LeetCode's list), a term whose tag a topic now teaches, and a term icon * that is missing, a topic's (`TOPIC_ICONS`), or another term's — every * badge linking to the term shows it. * * The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`, * ordered by topic number via a script-owned `meta.ts`). A problem outside the * plan (warm-ups) has no topic, so it groups under the data-structure CATEGORY * its file sits under in `work/1///` instead. * * Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`. * The phase folder is a Blume `(group)` — a sidebar section 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. A phase folder's `meta.ts` is written once, when * missing, then hand-owned (its title is yours to rename); a topic's is * script-owned. 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"; import { APPENDIX } from "../docs/appendix.ts"; import { TOPIC_LANDINGS } from "../docs/topic-landings.ts"; import { github } from "./github.ts"; 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. Written only when the folder has none — the title is hand-owned after * that. Nothing is lost by never rewriting it: the order and name it is built * from are also the folder name (`phaseDir`), so a README change to either * lands in a new folder that gets a fresh meta. */ 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`; } // ── LeetCode topic tags ────────────────────────────────────────── /** One of LeetCode's topic tags. `slug` is not derivable from `name` (`Graph Theory` → `graph`). */ interface TopicTag { name: string; slug: string; } /** * The problem's topic tags, in LeetCode's order — or undefined, warned, when * LeetCode cannot answer (offline commit, unknown slug): the page is written * without `topics:` and the next sync fills it in. */ async function fetchTopicTags(sol: Solution): Promise<TopicTag[] | undefined> { try { const res = await fetch("https://leetcode.com/graphql", { method: "POST", headers: { "content-type": "application/json", "user-agent": "Mozilla/5.0", referer: `https://leetcode.com/problems/${sol.slug}/`, }, body: JSON.stringify({ query: "query topicTags($titleSlug: String!) { question(titleSlug: $titleSlug) { topicTags { name slug } } }", variables: { titleSlug: sol.slug }, }), }); if (!res.ok) throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`); const data = (await res.json()) as { data?: { question?: { topicTags?: TopicTag[] } | null } }; const tags = data.data?.question?.topicTags; if (!Array.isArray(tags)) throw new Error("POST /graphql -> no such question"); return tags.map(({ name, slug }) => ({ name, slug })); } catch (err) { console.warn(`no topics for ${sol.num}.${sol.slug}: ${err instanceof Error ? err.message : err}`); return undefined; } } /** The frontmatter `topics:` block, one flow mapping per tag (JSON strings are valid YAML). */ function topicsYaml(tags: TopicTag[]): string { if (tags.length === 0) return "topics: []"; return ["topics:", ...tags.map((t) => ` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`)].join("\n"); } /** The frontmatter's `topics:` as sync wrote them (or a human edited them); [] when absent. */ function frontmatterTopics(page: string): TopicTag[] { const fm = page.match(/^---\n([\s\S]*?)\n---/)?.[1]; const data: unknown = fm ? Bun.YAML.parse(fm) : undefined; if (!data || typeof data !== "object" || !("topics" in data) || !Array.isArray(data.topics)) return []; return data.topics.filter((t): t is TopicTag => typeof t?.name === "string" && typeof t?.slug === "string"); } // ── GitHub problem issues ──────────────────────────────────────── let issueUrls: Promise<Map<number, string> | undefined> | undefined; /** * LC number → the URL of its `problem` issue (`LC <num> · <Name>`, open or * closed), listed once per run and only when a page first needs it — or * undefined, warned, when GitHub cannot answer (offline commit, no `gh` * auth): pages go without `issue:` and the next sync fills it in. */ function problemIssues(): Promise<Map<number, string> | undefined> { issueUrls ??= (async () => { try { const gh = await github(); const urls = new Map<number, string>(); for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=all`)) { if (!raw || typeof raw !== "object" || "pull_request" in raw) continue; if (!("title" in raw) || typeof raw.title !== "string") continue; if (!("html_url" in raw) || typeof raw.html_url !== "string") continue; const lc = raw.title.match(/^LC (\d+) · /)?.[1]; if (lc && !urls.has(Number(lc))) urls.set(Number(lc), raw.html_url); } return urls; } catch (err) { console.warn(`no problem issues: ${err instanceof Error ? err.message : err}`); return undefined; } })(); return issueUrls; } /** * The problem's issue URL, or undefined (warned when GitHub answered but has * no such issue). Only a problem the README plan lists has a catalog issue; * an unplanned warm-up never will, so it is not looked up — otherwise its * missing key would re-list the issues and re-warn on every sync. */ async function issueUrl(sol: Solution): Promise<string | undefined> { if (!topics.has(sol.num)) return undefined; const urls = await problemIssues(); const url = urls?.get(sol.num); if (urls && !url) console.warn(`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`); return url; } // ── 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)) ?? "" ); } /** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */ const PAGE_ICON = "dot"; function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string | undefined): 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}`, `icon: ${PAGE_ICON}`, ...(tags ? [topicsYaml(tags)] : []), ...(issue ? [`issue: ${JSON.stringify(issue)}`] : []), "sidebar:", ` label: '${h.title.replaceAll("'", "''")}'`, ` badge: '${h.difficulty}'`, "---", "", ); 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}`); } /** Set the frontmatter's top-level `icon:` to `PAGE_ICON`, inserting it before `sidebar:`. */ function ensureIcon(page: string): string { const fm = page.match(/^---\n[\s\S]*?\n---/); if (!fm) return page; const line = `icon: ${PAGE_ICON}`; const next = /^icon: .*$/m.test(fm[0]) ? fm[0].replace(/^icon: .*$/m, line) : fm[0].replace(/^sidebar:$/m, `${line}\nsidebar:`); return next + page.slice(fm[0].length); } /** Insert `topics:` before `sidebar:` when the frontmatter lacks it; an existing list is kept as is. */ async function ensureTopics(page: string, sol: Solution): Promise<string> { const fm = page.match(/^---\n[\s\S]*?\n---/); if (!fm || /^topics:/m.test(fm[0])) return page; const tags = await fetchTopicTags(sol); if (!tags) return page; return fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) + page.slice(fm[0].length); } /** Insert `issue:` before `sidebar:` when the frontmatter lacks it; an existing URL is kept as is. */ async function ensureIssue(page: string, sol: Solution): Promise<string> { const fm = page.match(/^---\n[\s\S]*?\n---/); if (!fm || /^issue:/m.test(fm[0])) return page; const url = await issueUrl(sol); if (!url) return page; return fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\nsidebar:`) + page.slice(fm[0].length); } /** * 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 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 const tags = new Map<string, TopicTag>(); // every tag slug any problem page carries -> tag 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 }); let written: string; // the page as it stands after this run, for its topics 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 = await ensureIssue(await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol), 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)"]); written = next; } else { const page = renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol)); await Bun.write(join(DOCS, name), page); report.push([workRef, name, "created"]); written = page; } for (const tag of frontmatterTopics(written)) tags.set(tag.slug, tag); // 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"]); } } // Every carried tag no campaign topic teaches is an Appendix term, filed by // hand in apps/docs/appendix.ts: warn about the gaps and the dead weight. A // term's icon is also the icon on every problem-page badge linking to its // card, so it must not be a topic group's (`TOPIC_ICONS`), so a term badge // never reads as a topic's, nor another term's. for (const tag of [...tags.values()].sort((a, b) => a.slug.localeCompare(b.slug))) { if (!TOPIC_LANDINGS[tag.slug] && !APPENDIX[tag.slug]) console.warn(`tag ${tag.slug}: no Appendix term; add ${JSON.stringify(tag.name)} to apps/docs/appendix.ts`); } const topicIcons = new Set(Object.values(TOPIC_ICONS)); const termIcons = new Map<string, string>(); // icon -> the term that took it first for (const [slug, term] of Object.entries(APPENDIX)) { if (TOPIC_LANDINGS[slug]) console.warn(`appendix ${slug}: a campaign topic teaches it now; delete its term`); if (topicIcons.has(term.icon)) console.warn(`appendix ${slug}: icon ${JSON.stringify(term.icon)} is a topic's (TOPIC_ICONS); pick another`); else if (termIcons.has(term.icon)) console.warn(`appendix ${slug}: icon ${JSON.stringify(term.icon)} is ${termIcons.get(term.icon)}'s too; pick another`); else termIcons.set(term.icon, slug); } // Phase metas: created when missing (an empty file counts as missing), never // rewritten, so a renamed phase title survives every sync. for (const [dir, phase] of phases) { const file = Bun.file(join(DOCS, dir, "meta.ts")); if ((await file.exists()) && file.size > 0) continue; await Bun.write(file, phaseMeta(phase)); report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]); } // Script-owned folder metas — one per live topic folder — rewritten only when // they differ. const metas: [label: string, dir: string, meta: string][] = [...groups].map(([dir, topic]) => [ `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}`);