2026-08-25 11:19:26 -04:00
|
|
|
#!/usr/bin/env bun
|
|
|
|
|
/**
|
2026-10-05 13:51:14 -04:00
|
|
|
* Sync work/1 leetcode solutions into docs/ pages, plus docs/site.json.
|
|
|
|
|
*
|
|
|
|
|
* docs/ is content only — MDX pages and one generated JSON manifest, no Blume
|
|
|
|
|
* config. The docs site itself (config, components, islands, the Appendix)
|
|
|
|
|
* lives in prdlk/cs and reads docs/ over HTTP with Blume's `mdxRemote()`
|
|
|
|
|
* source, which fetches `.md`/`.mdx` and nothing else: no `meta.ts`. So the
|
|
|
|
|
* sidebar meta a folder `meta.ts` used to carry, and the `/<num>` short links,
|
|
|
|
|
* travel in `docs/site.json` instead, which the site fetches at the same ref.
|
2026-08-25 11:19:26 -04:00
|
|
|
*
|
2026-09-28 11:19:32 -04:00
|
|
|
* - New problems get a full page (frontmatter, warning, examples,
|
2026-08-25 11:19:26 -04:00
|
|
|
* constraints, solution) in the gold-standard format.
|
2026-09-21 17:15:56 -04:00
|
|
|
* - Already-ported pages only get their script-owned parts regenerated — the
|
2026-09-28 11:19:32 -04:00
|
|
|
* frontmatter `icon:` (`PAGE_ICON`) and the `## Solution` section — so
|
|
|
|
|
* hand-curated prose is never touched.
|
2026-08-25 11:19:26 -04:00
|
|
|
* - 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.
|
2026-09-28 11:19:32 -04:00
|
|
|
* - 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
|
2026-10-05 13:51:14 -04:00
|
|
|
* renders no badges: the site's MDX `wrapper` (prdlk/cs
|
|
|
|
|
* `components/ProblemBadges.astro`) turns `topics:` and the `video:` key
|
|
|
|
|
* `videos.ts` writes into the badge row under the description.
|
2026-09-30 18:00:58 -04:00
|
|
|
* - 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 <num> · …`), 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.
|
2026-08-31 10:41:21 -04:00
|
|
|
*
|
2026-10-05 13:51:14 -04:00
|
|
|
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`).
|
|
|
|
|
* 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-21 17:15:56 -04:00
|
|
|
*
|
2026-09-24 15:57:38 -04:00
|
|
|
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
|
2026-09-29 15:12:52 -04:00
|
|
|
* 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
|
2026-10-05 13:07:52 -04:00
|
|
|
* it. The topic's problem pages sit one level down in a `(problems)` group,
|
|
|
|
|
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either,
|
|
|
|
|
* and Blume strips the `<num>_` prefix from the route while sorting by it, so
|
|
|
|
|
* the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves
|
2026-10-05 13:51:14 -04:00
|
|
|
* an `index.mdx`, and never removes a folder that still holds one. An
|
|
|
|
|
* unplanned category group has no phase and sits at the docs/ root.
|
|
|
|
|
*
|
|
|
|
|
* `docs/site.json` is wholly script-owned and rewritten whenever it differs:
|
|
|
|
|
* - `folders`: Blume folder meta keyed by folder path under docs/ — exactly
|
|
|
|
|
* the key a `meta.ts` in that folder would have. A phase is `N. Name` in
|
|
|
|
|
* phase order; a topic is its exact name (the folder slug loses `/` and
|
|
|
|
|
* case), curriculum order, `TOPIC_ICONS` icon and `display: "page"`, so it
|
|
|
|
|
* is one sidebar row drilling into its own panel. `(problems)` has none:
|
|
|
|
|
* Blume's humanized "Problems" is the label.
|
|
|
|
|
* - `redirects`: one `{ from: "/<num>", to: "/<topic>/<slug>" }` per page, in
|
|
|
|
|
* number order — exact root-relative paths; Blume prepends the deployment
|
|
|
|
|
* base itself.
|
2026-09-21 17:37:19 -04:00
|
|
|
*
|
2026-08-31 10:41:21 -04:00
|
|
|
* 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`.
|
2026-08-25 11:19:26 -04:00
|
|
|
*/
|
2026-10-05 13:51:14 -04:00
|
|
|
import { mkdir, readdir, rename, rmdir } from "node:fs/promises";
|
2026-09-21 17:15:56 -04:00
|
|
|
import { basename, dirname, join } from "node:path";
|
2026-08-25 11:19:26 -04:00
|
|
|
|
2026-09-30 17:58:06 -04:00
|
|
|
import { github } from "./github.ts";
|
2026-09-28 11:19:32 -04:00
|
|
|
|
2026-08-25 11:19:26 -04:00
|
|
|
const ROOT = join(import.meta.dir, "..", "..");
|
2026-08-31 10:41:21 -04:00
|
|
|
const WORK = join(ROOT, "work", "1");
|
2026-10-05 13:51:14 -04:00
|
|
|
const DOCS = join(ROOT, "docs");
|
|
|
|
|
const MANIFEST = join(DOCS, "site.json");
|
2026-08-25 11:19:26 -04:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-21 17:15:56 -04:00
|
|
|
/** 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-21 17:15:56 -04:00
|
|
|
}
|
|
|
|
|
|
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"];
|
|
|
|
|
|
2026-08-25 11:19:26 -04:00
|
|
|
const TOPIC_ALIASES: Record<string, string> = {
|
|
|
|
|
"Hash-Based Lookup": "Hash Table",
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-24 14:45:09 -04:00
|
|
|
/**
|
|
|
|
|
* 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",
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-21 17:15:56 -04:00
|
|
|
/** LC number -> topic. A problem listed under two topics keeps the first. */
|
|
|
|
|
async function readmeTopics(): Promise<Map<number, Topic>> {
|
2026-08-25 11:19:26 -04:00
|
|
|
const md = await Bun.file(join(ROOT, "README.md")).text();
|
2026-09-21 17:15:56 -04:00
|
|
|
const map = new Map<number, Topic>();
|
2026-09-24 15:57:38 -04:00
|
|
|
let phase: Phase | undefined;
|
2026-09-21 17:15:56 -04:00
|
|
|
let topic: Topic | null = null;
|
2026-08-25 11:19:26 -04:00
|
|
|
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("&", "&");
|
|
|
|
|
phase = { order: ROMAN.indexOf(p[1]!) + 1, name: PHASE_ALIASES[name] ?? name };
|
|
|
|
|
}
|
2026-09-21 17:15:56 -04:00
|
|
|
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 };
|
2026-08-25 11:19:26 -04:00
|
|
|
if (!topic) continue;
|
|
|
|
|
const n = line.match(/<td align="center">(\d+)<\/td>/);
|
2026-09-21 17:15:56 -04:00
|
|
|
if (n && !map.has(Number(n[1]))) map.set(Number(n[1]), topic);
|
2026-08-25 11:19:26 -04:00
|
|
|
}
|
|
|
|
|
return map;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 13:06:20 -04:00
|
|
|
/**
|
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)})`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 13:51:14 -04:00
|
|
|
/** A sidebar group's Blume folder meta, as `docs/site.json` carries it. */
|
|
|
|
|
interface FolderMeta {
|
|
|
|
|
title: string;
|
|
|
|
|
order: number;
|
|
|
|
|
icon?: string;
|
|
|
|
|
display?: "page";
|
2026-09-24 15:57:38 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-05 13:51:14 -04:00
|
|
|
* Folder meta for a phase `(group)`: the overview page's heading, `N. Name`,
|
|
|
|
|
* in phase order. Derived like everything in the manifest, so a README rename
|
|
|
|
|
* renames the heading — and lands in a new folder (`phaseDir`) anyway.
|
2026-09-22 13:06:20 -04:00
|
|
|
*/
|
2026-10-05 13:51:14 -04:00
|
|
|
function phaseMeta(phase: Phase): FolderMeta {
|
|
|
|
|
return { title: `${phase.order}. ${phase.name}`, order: phase.order };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Folder meta for a topic: exact label, curriculum order, the sidebar icon,
|
|
|
|
|
* and `display: "page"` so the topic is one row under its phase heading that
|
|
|
|
|
* drills into its own panel (the landing page, then its `(problems)` list).
|
|
|
|
|
* 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): FolderMeta {
|
2026-09-24 14:45:09 -04:00
|
|
|
const icon = TOPIC_ICONS[topic.name];
|
|
|
|
|
if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`);
|
2026-10-05 13:51:14 -04:00
|
|
|
return { title: topic.name, order: topic.order ?? 0, ...(icon ? { icon } : {}), display: "page" };
|
2026-09-21 17:15:56 -04:00
|
|
|
}
|
|
|
|
|
|
2026-09-28 11:19:32 -04:00
|
|
|
// ── 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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 17:58:06 -04:00
|
|
|
// ── 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 18:00:58 -04:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-09-30 17:58:06 -04:00
|
|
|
async function issueUrl(sol: Solution): Promise<string | undefined> {
|
2026-09-30 18:00:58 -04:00
|
|
|
if (!topics.has(sol.num)) return undefined;
|
2026-09-30 17:58:06 -04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 11:19:26 -04:00
|
|
|
// ── 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)) ?? ""
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 13:14:22 -04:00
|
|
|
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
|
2026-09-25 13:17:31 -04:00
|
|
|
const PAGE_ICON = "dot";
|
2026-09-25 13:14:22 -04:00
|
|
|
|
2026-09-30 17:58:06 -04:00
|
|
|
function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string | undefined): string {
|
2026-08-25 11:19:26 -04:00
|
|
|
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}`,
|
2026-09-25 13:14:22 -04:00
|
|
|
`icon: ${PAGE_ICON}`,
|
2026-09-28 11:19:32 -04:00
|
|
|
...(tags ? [topicsYaml(tags)] : []),
|
2026-09-30 17:58:06 -04:00
|
|
|
...(issue ? [`issue: ${JSON.stringify(issue)}`] : []),
|
2026-08-25 11:19:26 -04:00
|
|
|
"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) {
|
2026-08-31 10:41:21 -04:00
|
|
|
console.warn(`skip (unrecognized name): work/1/${rel}`);
|
2026-08-25 11:19:26 -04:00
|
|
|
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);
|
2026-08-25 11:19:26 -04:00
|
|
|
|
|
|
|
|
/** 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}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 13:14:22 -04:00
|
|
|
/** 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 11:19:32 -04:00
|
|
|
/** 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 17:58:06 -04:00
|
|
|
/** 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 14:21:50 -04:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-09-28 11:19:32 -04:00
|
|
|
* appends after the solution survive regeneration.
|
2026-09-01 14:21:50 -04:00
|
|
|
*/
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 11:19:26 -04:00
|
|
|
// 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-10-05 13:51:14 -04:00
|
|
|
const folders = new Map<string, FolderMeta>(); // folder under docs/ -> its sidebar meta, for site.json
|
2026-09-21 17:15:56 -04:00
|
|
|
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
|
2026-10-05 13:51:14 -04:00
|
|
|
const redirects: { from: string; to: string }[] = []; // one per page, numeric order
|
2026-08-25 11:19:26 -04:00
|
|
|
|
|
|
|
|
for (const sol of ordered) {
|
|
|
|
|
const langs = Object.keys(sol.code).sort().join("+");
|
|
|
|
|
const workRef = `${sol.num}.${sol.slug} (${langs})`;
|
2026-10-05 13:07:52 -04:00
|
|
|
// (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment,
|
|
|
|
|
// so its index.mdx is the landing page; `(problems)` adds none) +
|
|
|
|
|
// leetcode-number prefix: numeric sidebar order, prefix stripped from the URL.
|
2026-09-24 15:57:38 -04:00
|
|
|
const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category };
|
|
|
|
|
const dir = topicDir(topic);
|
2026-10-05 13:51:14 -04:00
|
|
|
if (!folders.has(dir)) folders.set(dir, groupMeta(topic));
|
|
|
|
|
if (topic.phase) folders.set(phaseDir(topic.phase), phaseMeta(topic.phase));
|
2026-10-05 13:07:52 -04:00
|
|
|
const name = `${dir}/(problems)/${sol.num}_${sol.slug}.mdx`;
|
2026-08-25 11:19:26 -04:00
|
|
|
const existing = pages.get(sol.num);
|
2026-10-05 13:07:52 -04:00
|
|
|
await mkdir(join(DOCS, dirname(name)), { recursive: true });
|
2026-08-25 11:19:26 -04:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-09-01 14:21:50 -04:00
|
|
|
const tail = page.slice(solutionEnd(page, at));
|
|
|
|
|
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
|
2026-09-30 17:58:06 -04:00
|
|
|
const next = await ensureIssue(await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol), sol);
|
2026-08-25 11:19:26 -04:00
|
|
|
const actions: string[] = [];
|
|
|
|
|
if (existing !== name) {
|
|
|
|
|
await rename(join(DOCS, existing), join(DOCS, name));
|
2026-09-21 17:15:56 -04:00
|
|
|
vacated.add(dirname(existing));
|
2026-08-25 11:19:26 -04:00
|
|
|
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)"]);
|
|
|
|
|
} else {
|
2026-10-05 13:51:14 -04:00
|
|
|
await Bun.write(join(DOCS, name), renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol)));
|
2026-08-25 11:19:26 -04:00
|
|
|
report.push([workRef, name, "created"]);
|
2026-09-29 15:37:22 -04:00
|
|
|
}
|
2026-10-05 13:51:14 -04:00
|
|
|
// The route drops the phase `(group)`, `(problems)` and the number prefix.
|
|
|
|
|
redirects.push({ from: `/${sol.num}`, to: `/${slugify(topic.name)}/${sol.slug}` });
|
2026-09-24 16:15:49 -04:00
|
|
|
}
|
|
|
|
|
|
2026-10-05 13:51:14 -04:00
|
|
|
// The site's one generated input besides the pages; script-owned, rewritten
|
|
|
|
|
// only when it differs. Folders sorted by path so a diff reads by location.
|
2026-09-24 16:15:49 -04:00
|
|
|
{
|
2026-10-05 13:51:14 -04:00
|
|
|
const manifest = `${JSON.stringify(
|
|
|
|
|
{ folders: Object.fromEntries([...folders].sort(([a], [b]) => (a < b ? -1 : 1))), redirects },
|
|
|
|
|
null,
|
|
|
|
|
2,
|
|
|
|
|
)}\n`;
|
|
|
|
|
if (!(await Bun.file(MANIFEST).exists()) || (await Bun.file(MANIFEST).text()) !== manifest) {
|
|
|
|
|
await Bun.write(MANIFEST, manifest);
|
|
|
|
|
report.push([`${folders.size} folders, ${redirects.length} redirects`, "docs/site.json", "written"]);
|
2026-09-24 16:15:49 -04:00
|
|
|
}
|
2026-08-25 11:19:26 -04:00
|
|
|
}
|
|
|
|
|
|
2026-10-05 13:51:14 -04:00
|
|
|
// A folder every page left behind is dead: drop it, then the folders above it
|
|
|
|
|
// that emptied too (`(problems)`, its topic, the phase). A folder that still
|
|
|
|
|
// holds anything (a hand-written index.mdx, another topic) stops the climb.
|
2026-09-24 15:57:38 -04:00
|
|
|
for (const start of vacated) {
|
|
|
|
|
for (let dir = start; dir !== "."; dir = dirname(dir)) {
|
2026-10-05 13:51:14 -04:00
|
|
|
if (folders.has(dir)) break;
|
2026-09-24 15:57:38 -04:00
|
|
|
let left: string[];
|
|
|
|
|
try {
|
|
|
|
|
left = await readdir(join(DOCS, dir));
|
|
|
|
|
} catch {
|
|
|
|
|
break; // already removed via a sibling's climb
|
|
|
|
|
}
|
2026-10-05 13:51:14 -04:00
|
|
|
if (left.length) break;
|
2026-09-24 15:57:38 -04:00
|
|
|
await rmdir(join(DOCS, dir));
|
|
|
|
|
report.push(["", dir, "removed (empty)"]);
|
|
|
|
|
}
|
2026-09-21 17:15:56 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-25 11:19:26 -04:00
|
|
|
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}`);
|