diff --git a/apps/cli/sync.ts b/apps/cli/sync.ts index 023d43c..6603522 100755 --- a/apps/cli/sync.ts +++ b/apps/cli/sync.ts @@ -4,18 +4,25 @@ * * - 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. + * - 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, rename } from "node:fs/promises"; -import { basename, join } from "node:path"; +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"); @@ -158,24 +165,42 @@ function parseHeader(header: string): Header { // ── README topic map ───────────────────────────────────────────── +/** A campaign topic: `NN. Name` 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 = { "Hash-Based Lookup": "Hash Table", }; -async function readmeTopics(): Promise> { +/** LC number -> topic. A problem listed under two topics keeps the first. */ +async function readmeTopics(): Promise> { const md = await Bun.file(join(ROOT, "README.md")).text(); - const map = new Map(); - let topic = ""; + const map = new Map(); + let topic: Topic | null = null; for (const line of md.split("\n")) { - const t = line.match(/\d+\.\s*([^<]+)<\/strong>/); - if (t) topic = TOPIC_ALIASES[t[1]!.trim()] ?? t[1]!.trim(); + const t = line.match(/(\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(/(\d+)<\/td>/); - if (n) map.set(Number(n[1]), topic); + 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. */ @@ -277,7 +302,11 @@ function warningText(h: Header): string { ); } -function renderPage(sol: Solution, topic: string): string { +function badgeLine(sol: Solution): string { + return `${sol.category}`; +} + +function renderPage(sol: Solution): string { const h = sol.header; const ids = identifiers(sol); const description = h.statement[0]?.replace(/\.\s*$/, "") ?? ""; @@ -292,7 +321,7 @@ function renderPage(sol: Solution, topic: string): string { ` badge: '${h.difficulty}'`, "---", "", - `${topic}`, + badgeLine(sol), "", ); @@ -387,12 +416,18 @@ for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).s const report: [string, string, string][] = []; const ordered = [...solutions.values()].sort((a, b) => a.num - b.num); +const groups = new Map(); // folder -> topic, for meta.ts +const vacated = new Set(); // 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})`; - // (category) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL. - const group = `(${sol.category.toLowerCase().replaceAll(" ", "-")})`; + // (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 }); @@ -406,10 +441,11 @@ for (const sol of ordered) { } const tail = page.slice(solutionEnd(page, at)); const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : ""); - const next = ensureLabel(body, sol.header); + const next = ensureLabel(body, sol.header).replace(/^.*<\/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) { @@ -418,12 +454,30 @@ for (const sol of ordered) { } 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)); + 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`);