Files
leetcode/apps/cli/sync.ts
T

848 lines
35 KiB
TypeScript
Raw Normal View History

#!/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 <CodeGroup> (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 tag a page carries that no campaign topic teaches (not a key of
* `apps/docs/topic-landings.ts`) gets a placeholder guide page,
* `guides/<slug>.mdx`, with `list:` pointing at LeetCode's problem list for
* the tag. Created once (a missing or empty file), then hand-owned except
* for `sidebar.badge`, which sync keeps at the guide's backlink count (the
* problem pages carrying the tag; dropped at zero); the tag's badge on a
* problem page links to it. A guide may be filed into a category `(group)`
* folder (e.g. `guides/(2-algorithms)/`) — no route segment, so
* `/guides/<slug>` holds — and is found there by slug; a new placeholder
* lands at the `guides/` root for a human to file (and give an `icon:`,
* which every badge linking to the guide shows; sync warns while one is
* missing or shared).
*
* 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/<Difficulty>/<Category>/` instead.
*
2026-09-24 15:57:38 -04:00
* 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: `/<num>` → `/<topic>/<slug>` (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 { TOPIC_LANDINGS } from "../docs/topic-landings.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<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 ─────────────────────────────────────────────
2026-09-24 15:57:38 -04:00
/** 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;
2026-09-24 15:57:38 -04:00
phase?: Phase; // absent for an unplanned category group
}
2026-09-24 15:57:38 -04:00
/** 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>();
2026-09-24 15:57:38 -04:00
let phase: Phase | undefined;
let topic: Topic | null = null;
for (const line of md.split("\n")) {
2026-09-24 15:57:38 -04:00
const p = line.match(/^## Phase ([IVX]+) — (.+?) · /);
if (p) {
const name = p[2]!.replaceAll("&amp;", "&");
phase = { order: ROMAN.indexOf(p[1]!) + 1, name: PHASE_ALIASES[name] ?? name };
}
const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/);
2026-09-24 15:57:38 -04:00
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;
}
/**
2026-09-24 15:57:38 -04:00
* 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.
*/
2026-09-24 15:57:38 -04:00
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)}`);
2026-09-24 15:57:38 -04:00
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");
}
// ── guides ───────────────────────────────────────────────────────
/**
* Placeholder for a tag no campaign topic teaches: the `/guides/<slug>` page a
* problem's topic badge links to. `list:` is the tag's LeetCode problem list,
* rendered as a badge by the docs MDX `wrapper`. Written once — the page
* is hand-owned from then on.
*/
function guidePage(tag: TopicTag): string {
return [
"---",
`title: ${JSON.stringify(tag.name)}`,
`list: ${JSON.stringify(`https://leetcode.com/problem-list/${tag.slug}/`)}`,
"---",
"",
":::note",
`This guide has not been written yet. The LeetCode list above has every problem tagged ${tag.name}.`,
":::",
"",
].join("\n");
}
/** Folder meta for `guides/`: its own header tab (`navigation.tabs` in blume.config.ts), so only the label. */
const GUIDES_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Guides" });\n`;
/**
* A guide's frontmatter with `sidebar.badge` set to its backlink count — the
* one key sync owns on a hand-owned guide — or dropped (with a `sidebar:` left
* empty) at zero, so the sidebar shows a count only where there is something
* to count.
*/
function withBacklinkBadge(page: string, count: number): string {
const fm = page.match(/^---\n[\s\S]*?\n---/);
if (!fm) return page;
let head = fm[0].replace(/^ {2}badge: .*\n/m, "");
if (count > 0) {
const line = ` badge: '${count}'`;
head = /^sidebar:$/m.test(head)
? head.replace(/^sidebar:$/m, `sidebar:\n${line}`)
: head.replace(/\n---$/, `\nsidebar:\n${line}\n---`);
} else {
head = head.replace(/^sidebar:\n(?! {2})/m, "");
}
return head + page.slice(fm[0].length);
}
// ── 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): 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)] : []),
"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();
2026-09-24 15:57:38 -04:00
// 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);
}
/**
* 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);
2026-09-24 15:57:38 -04:00
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
const tagged = new Map<string, number>(); // tag slug -> how many problem pages carry it
for (const sol of ordered) {
const langs = Object.keys(sol.code).sort().join("+");
const workRef = `${sol.num}.${sol.slug} (${langs})`;
2026-09-24 15:57:38 -04:00
// (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);
2026-09-24 15:57:38 -04:00
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 ensureTopics(ensureIcon(ensureLabel(body, sol.header)), 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 Bun.write(join(DOCS, name), page);
report.push([workRef, name, "created"]);
written = page;
}
for (const tag of frontmatterTopics(written)) {
tags.set(tag.slug, tag);
tagged.set(tag.slug, (tagged.get(tag.slug) ?? 0) + 1);
}
// 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"]);
}
}
// One guide page per tag that no campaign topic teaches, created when missing
// (an empty file counts as missing) and never rewritten. A guide is found by
// slug anywhere under guides/ — a human files it into a category `(group)`,
// which leaves its route alone — and a new one lands at the guides/ root. A
// guide whose tag no page carries any more is left alone: it is hand-owned.
const guideTags = [...tags.values()]
.filter((tag) => !TOPIC_LANDINGS[tag.slug])
.sort((a, b) => a.slug.localeCompare(b.slug));
const guideFiles = new Map<string, string>(); // slug -> path relative to DOCS
for await (const rel of new Bun.Glob("guides/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel);
for (const tag of guideTags) {
const rel = guideFiles.get(tag.slug) ?? `guides/${tag.slug}.mdx`;
const file = Bun.file(join(DOCS, rel));
if ((await file.exists()) && file.size > 0) continue;
await Bun.write(file, guidePage(tag));
report.push([`tag ${tag.slug}`, rel, "created"]);
}
// Every guide's sidebar badge is its backlink count: the problem pages whose
// tag badge links to it, which is every page carrying the tag, since a guide's
// tag has no landing (`tagTarget()`). A guide whose tag a landing has since
// claimed has no backlinks left. Counts move with every solved problem, so the
// badge is rewritten whenever it differs; the rest of the guide is untouched.
// The same pass checks each guide's hand-picked `icon:`, which is also the icon
// on every problem-page badge linking to the guide: it must exist, must not be
// a topic group's (`TOPIC_ICONS`), so a guide badge never reads as a topic's,
// and must not be another guide's.
const topicIcons = new Set(Object.values(TOPIC_ICONS));
const guideIcons = new Map<string, string>(); // icon -> the guide that took it first
for await (const rel of new Bun.Glob("guides/**/*.mdx").scan(DOCS)) {
const slug = basename(rel, ".mdx");
const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0);
const page = await Bun.file(join(DOCS, rel)).text();
const icon = page.match(/^---\n[\s\S]*?\n---/)?.[0].match(/^icon: (\S+)$/m)?.[1];
if (!icon) console.warn(`${rel}: no icon; its badges render without one — file it and add \`icon:\``);
else if (topicIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is a topic's (TOPIC_ICONS); pick another`);
else if (guideIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is ${guideIcons.get(icon)}'s too; pick another`);
else guideIcons.set(icon, rel);
const next = withBacklinkBadge(page, count);
if (next === page) continue;
await Bun.write(join(DOCS, rel), next);
report.push([`${count} backlinks`, rel, "badged"]);
}
// 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, plus guides/ —
// rewritten only when they differ.
2026-09-24 15:57:38 -04:00
const metas: [label: string, dir: string, meta: string][] = [
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
...(guideTags.length ? [["guides", "guides", GUIDES_META] as [string, string, string]] : []),
2026-09-24 15:57:38 -04:00
];
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);
2026-09-24 15:57:38 -04:00
report.push([label, `${dir}/meta.ts`, "written"]);
}
2026-09-24 15:57:38 -04:00
// 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}`);