mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
feat(cli): add topic grouping with meta.ts generation in sync script
This commit is contained in:
1 parent
0c12df3eba
commit
f9e69960c4
1 file changed
+71
-17
+71
-17
@@ -4,18 +4,25 @@
|
|||||||
*
|
*
|
||||||
* - New problems get a full page (frontmatter, badge, warning, examples,
|
* - New problems get a full page (frontmatter, badge, warning, examples,
|
||||||
* constraints, solution) in the gold-standard format.
|
* constraints, solution) in the gold-standard format.
|
||||||
* - Already-ported pages only get their `## Solution` section regenerated,
|
* - Already-ported pages only get their script-owned parts regenerated — the
|
||||||
* so hand-curated prose is never touched.
|
* `<Badge>` line and the `## Solution` section — so hand-curated prose is
|
||||||
|
* never touched.
|
||||||
* - When a problem is solved in both JavaScript and Python the solution
|
* - When a problem is solved in both JavaScript and Python the solution
|
||||||
* renders as a <CodeGroup> (Python first); single-language solutions
|
* renders as a <CodeGroup> (Python first); single-language solutions
|
||||||
* render as a plain fence.
|
* 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/<Difficulty>/<Category>/`. 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
|
* 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
|
* of problems whose page already exists (see work.ts), and a fresh scaffold
|
||||||
* there would otherwise wipe the page's `## Solution`.
|
* there would otherwise wipe the page's `## Solution`.
|
||||||
*/
|
*/
|
||||||
import { mkdir, rename } from "node:fs/promises";
|
import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
||||||
import { basename, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
const WORK = join(ROOT, "work", "1");
|
const WORK = join(ROOT, "work", "1");
|
||||||
@@ -158,24 +165,42 @@ function parseHeader(header: string): Header {
|
|||||||
|
|
||||||
// ── README topic map ─────────────────────────────────────────────
|
// ── README topic map ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
const TOPIC_ALIASES: Record<string, string> = {
|
const TOPIC_ALIASES: Record<string, string> = {
|
||||||
"Hash-Based Lookup": "Hash Table",
|
"Hash-Based Lookup": "Hash Table",
|
||||||
};
|
};
|
||||||
|
|
||||||
async function readmeTopics(): Promise<Map<number, string>> {
|
/** 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 md = await Bun.file(join(ROOT, "README.md")).text();
|
||||||
const map = new Map<number, string>();
|
const map = new Map<number, Topic>();
|
||||||
let topic = "";
|
let topic: Topic | null = null;
|
||||||
for (const line of md.split("\n")) {
|
for (const line of md.split("\n")) {
|
||||||
const t = line.match(/<strong>\d+\.\s*([^<]+)<\/strong>/);
|
const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/);
|
||||||
if (t) topic = TOPIC_ALIASES[t[1]!.trim()] ?? t[1]!.trim();
|
if (t) topic = { order: Number(t[1]), name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim() };
|
||||||
if (!topic) continue;
|
if (!topic) continue;
|
||||||
const n = line.match(/<td align="center">(\d+)<\/td>/);
|
const n = line.match(/<td align="center">(\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;
|
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 ────────────────────────────
|
// ── inline-code formatting heuristics ────────────────────────────
|
||||||
|
|
||||||
/** Identifiers worth backticking in prose, harvested from the code. */
|
/** 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 `<Badge variant="accent">${sol.category}</Badge>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(sol: Solution): string {
|
||||||
const h = sol.header;
|
const h = sol.header;
|
||||||
const ids = identifiers(sol);
|
const ids = identifiers(sol);
|
||||||
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
|
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
|
||||||
@@ -292,7 +321,7 @@ function renderPage(sol: Solution, topic: string): string {
|
|||||||
` badge: '${h.difficulty}'`,
|
` badge: '${h.difficulty}'`,
|
||||||
"---",
|
"---",
|
||||||
"",
|
"",
|
||||||
`<Badge variant="accent">${topic}</Badge>`,
|
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 report: [string, string, string][] = [];
|
||||||
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
||||||
|
const groups = new Map<string, Topic>(); // folder -> topic, for meta.ts
|
||||||
|
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
|
||||||
|
|
||||||
for (const sol of ordered) {
|
for (const sol of ordered) {
|
||||||
const langs = Object.keys(sol.code).sort().join("+");
|
const langs = Object.keys(sol.code).sort().join("+");
|
||||||
const workRef = `${sol.num}.${sol.slug} (${langs})`;
|
const workRef = `${sol.num}.${sol.slug} (${langs})`;
|
||||||
// (category) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL.
|
// (topic) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL.
|
||||||
const group = `(${sol.category.toLowerCase().replaceAll(" ", "-")})`;
|
// 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 name = `${group}/${sol.num}-${sol.slug}.mdx`;
|
||||||
const existing = pages.get(sol.num);
|
const existing = pages.get(sol.num);
|
||||||
await mkdir(join(DOCS, group), { recursive: true });
|
await mkdir(join(DOCS, group), { recursive: true });
|
||||||
@@ -406,10 +441,11 @@ for (const sol of ordered) {
|
|||||||
}
|
}
|
||||||
const tail = page.slice(solutionEnd(page, at));
|
const tail = page.slice(solutionEnd(page, at));
|
||||||
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
|
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 variant="accent">.*<\/Badge>$/m, badgeLine(sol));
|
||||||
const actions: string[] = [];
|
const actions: string[] = [];
|
||||||
if (existing !== name) {
|
if (existing !== name) {
|
||||||
await rename(join(DOCS, existing), join(DOCS, name));
|
await rename(join(DOCS, existing), join(DOCS, name));
|
||||||
|
vacated.add(dirname(existing));
|
||||||
actions.push("moved");
|
actions.push("moved");
|
||||||
}
|
}
|
||||||
if (next !== page) {
|
if (next !== page) {
|
||||||
@@ -418,12 +454,30 @@ for (const sol of ordered) {
|
|||||||
}
|
}
|
||||||
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
||||||
} else {
|
} else {
|
||||||
const topic = topics.get(sol.num) ?? sol.category;
|
await Bun.write(join(DOCS, name), renderPage(sol));
|
||||||
await Bun.write(join(DOCS, name), renderPage(sol, topic));
|
|
||||||
report.push([workRef, name, "created"]);
|
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 w0 = Math.max(...report.map((r) => r[0].length), 9);
|
||||||
const w1 = Math.max(...report.map((r) => r[1].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(`${"work file".padEnd(w0)} ${"docs page".padEnd(w1)} status`);
|
||||||
|
|||||||
Reference in new issue
Block a user