feat(cli): add GitHub issue URLs to problem pages and rename guides to appendix

This commit is contained in:
Prad Nukala committed 2026-09-30 17:58:06 -04:00
1 parent 76c2a64fc6
commit c05e7f0ef2
2 files changed
+97 -33

No files matched your search

+12 -9
View File
@@ -14,17 +14,21 @@
* the same day. * the same day.
* *
* Each release is a changelog snapshot at that instant: the issue's problems * Each release is a changelog snapshot at that instant: the issue's problems
* (a topic's sub-issues plus those since re-parented into Review issues, or a * that had closed by then (a topic's sub-issues plus those since re-parented
* review's sub-issues in solve order — see topics.ts) grouped as closed on the * into Review issues, or a review's sub-issues in solve order — see
* finish day (ET), closed earlier, and open at release, each with its set, * topics.ts), grouped as closed on the finish day (ET) and closed earlier,
* difficulty, and the solution files as they stood in the tagged commit — the * each with its set, difficulty, and the solution files as they stood in the
* last main commit before the finish. * tagged commit — the last main commit before the finish. Problems still open
* at the finish are left out: the release records what finished, and an open
* optional problem is tracked by its issue (or its Review day), not here.
* apps/docs/lib/releases.ts reads these `### Closed …` tables back for the
* docs `/changelog` timeline, so their row shape is its contract.
* *
* The tag is annotated and backdated (tagger date = finish), so * The tag is annotated and backdated (tagger date = finish), so
* `git tag --sort=taggerdate` reads as the campaign's history even for tags * `git tag --sort=taggerdate` reads as the campaign's history even for tags
* minted by a backfill. The release's own published date cannot be backdated * minted by a backfill. The release's own published date cannot be backdated
* (the API has no field for it), so the docs changelog dates each entry from * (the API has no field for it), so the docs changelog dates each entry from
* this timever tag instead (`releasesDatedByTag` in apps/docs/blume.config.ts) * this timever tag instead (apps/docs/lib/releases.ts)
* — the tag format is that contract. * — the tag format is that contract.
* *
* Reconciles instead of reacting: every finished topic or review without a * Reconciles instead of reacting: every finished topic or review without a
@@ -201,11 +205,11 @@ function bodySection(body: string, heading: string): string | undefined {
function render(f: Finished, tag: string, sha: string, files: Map<number, string[]>): string { function render(f: Finished, tag: string, sha: string, files: Map<number, string[]>): string {
const { issue, problems } = f; const { issue, problems } = f;
const finishDay = etDate(new Date(f.finishedAt)); const finishDay = etDate(new Date(f.finishedAt));
// Snapshot at the finish instant: a problem closed later was open then. // Snapshot at the finish instant: a problem closed later was open then, and
// open problems are not part of the release.
const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt; const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt;
const onDay = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) === finishDay); const onDay = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) === finishDay);
const earlier = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) !== finishDay); const earlier = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) !== finishDay);
const open = problems.filter((p) => !closedBy(p));
const row = (p: Problem): string[] => { const row = (p: Problem): string[] => {
const lc = Number(p.title.match(/^LC (\d+)/)?.[1]); const lc = Number(p.title.match(/^LC (\d+)/)?.[1]);
@@ -248,7 +252,6 @@ function render(f: Finished, tag: string, sha: string, files: Map<number, string
const sections = [`<!-- release-topics: #${issue.number} -->\n${summary}`, fields.join(" · ")]; const sections = [`<!-- release-topics: #${issue.number} -->\n${summary}`, fields.join(" · ")];
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the ${f.kind} finished\n\n${table(onDay)}`); sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the ${f.kind} finished\n\n${table(onDay)}`);
if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`); if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
if (open.length) sections.push(`### Open at release\n\n${table(open)}`);
const notes = NOTES.flatMap((h) => { const notes = NOTES.flatMap((h) => {
const text = bodySection(issue.body, h); const text = bodySection(issue.body, h);
return text ? [`**${h}.** ${text}`] : []; return text ? [`**${h}.** ${text}`] : [];
+85 -24
View File
@@ -18,18 +18,25 @@
* renders no badges: the docs MDX `wrapper` * renders no badges: the docs MDX `wrapper`
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the * (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
* `video:` key `videos.ts` writes into the badge row under the description. * `video:` key `videos.ts` writes into the badge row under the description.
* - Every page carries its GitHub problem issue as frontmatter `issue:` (the
* issue's URL; the `problem`-labelled issue titled `LC <num> · …`), rendered
* by the same wrapper as a GitHub badge after the others. 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 * - Every tag a page carries that no campaign topic teaches (not a key of
* `apps/docs/topic-landings.ts`) gets a placeholder guide page, * `apps/docs/topic-landings.ts`) gets a placeholder guide page in the
* `guides/<slug>.mdx`, with `list:` pointing at LeetCode's problem list for * Appendix, `appendix/<slug>.mdx`, with `list:` pointing at LeetCode's
* the tag. Created once (a missing or empty file), then hand-owned except * problem list for the tag. Created once (a missing or empty file), then
* for `sidebar.badge`, which sync keeps at the guide's backlink count (the * hand-owned except for `sidebar.badge`, which sync keeps at the guide's
* problem pages carrying the tag; dropped at zero); the tag's badge on a * backlink count (the problem pages carrying the tag; dropped at zero); the
* problem page links to it. A guide may be filed into a category `(group)` * tag's badge on a problem page links to it. A guide may be filed into a
* folder (e.g. `guides/(2-algorithms)/`) — no route segment, so * category `(group)` folder (e.g. `appendix/(2-algorithms)/`) — no route
* `/guides/<slug>` holds — and is found there by slug; a new placeholder * segment, so `/appendix/<slug>` holds — and is found there by slug; a new
* lands at the `guides/` root for a human to file (and give an `icon:`, * placeholder lands at the `appendix/` root for a human to file (and give
* which every badge linking to the guide shows; sync warns while one is * an `icon:`, which every badge linking to the guide shows; sync warns
* missing or shared). * while one is missing or shared).
* *
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`, * 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 * ordered by topic number via a script-owned `meta.ts`). A problem outside the
@@ -60,6 +67,7 @@ import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
import { basename, dirname, join } from "node:path"; import { basename, dirname, join } from "node:path";
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts"; import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
import { github } from "./github.ts";
const ROOT = join(import.meta.dir, "..", ".."); const ROOT = join(import.meta.dir, "..", "..");
const WORK = join(ROOT, "work", "1"); const WORK = join(ROOT, "work", "1");
@@ -397,10 +405,49 @@ function frontmatterTopics(page: string): TopicTag[] {
return data.topics.filter((t): t is TopicTag => typeof t?.name === "string" && typeof t?.slug === "string"); 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). */
async function issueUrl(sol: Solution): Promise<string | 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;
}
// ── guides ─────────────────────────────────────────────────────── // ── guides ───────────────────────────────────────────────────────
/** /**
* Placeholder for a tag no campaign topic teaches: the `/guides/<slug>` page a * Placeholder for a tag no campaign topic teaches: the `/appendix/<slug>` page a
* problem's topic badge links to. `list:` is the tag's LeetCode problem list, * 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 * rendered as a badge by the docs MDX `wrapper`. Written once — the page
* is hand-owned from then on. * is hand-owned from then on.
@@ -419,8 +466,12 @@ function guidePage(tag: TopicTag): string {
].join("\n"); ].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`; * Folder meta for `appendix/`: a drill-in (`display: "page"`) sidebar row
* sorted ahead of the phase groups, so it sits right under the root pages
* (Introduction, Keywords, Progress).
*/
const APPENDIX_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Appendix", icon: "library", order: 0, display: "page" });\n`;
/** /**
* A guide's frontmatter with `sidebar.badge` set to its backlink count — the * A guide's frontmatter with `sidebar.badge` set to its backlink count — the
@@ -547,7 +598,7 @@ function warningText(h: Header): string {
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */ /** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
const PAGE_ICON = "dot"; const PAGE_ICON = "dot";
function renderPage(sol: Solution, tags: TopicTag[] | undefined): string { function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string | undefined): 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*$/, "") ?? "";
@@ -559,6 +610,7 @@ function renderPage(sol: Solution, tags: TopicTag[] | undefined): string {
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`, /[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
`icon: ${PAGE_ICON}`, `icon: ${PAGE_ICON}`,
...(tags ? [topicsYaml(tags)] : []), ...(tags ? [topicsYaml(tags)] : []),
...(issue ? [`issue: ${JSON.stringify(issue)}`] : []),
"sidebar:", "sidebar:",
` label: '${h.title.replaceAll("'", "''")}'`, ` label: '${h.title.replaceAll("'", "''")}'`,
` badge: '${h.difficulty}'`, ` badge: '${h.difficulty}'`,
@@ -654,6 +706,15 @@ async function ensureTopics(page: string, sol: Solution): Promise<string> {
return fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) + page.slice(fm[0].length); 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 * 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 * heading that is not inside a code fence, or end of file. Sections a human
@@ -713,7 +774,7 @@ 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 = await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol); const next = await ensureIssue(await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol), 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));
@@ -727,7 +788,7 @@ for (const sol of ordered) {
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]); report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
written = next; written = next;
} else { } else {
const page = renderPage(sol, await fetchTopicTags(sol)); const page = renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol));
await Bun.write(join(DOCS, name), page); await Bun.write(join(DOCS, name), page);
report.push([workRef, name, "created"]); report.push([workRef, name, "created"]);
written = page; written = page;
@@ -758,16 +819,16 @@ for (const sol of ordered) {
// One guide page per tag that no campaign topic teaches, created when missing // 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 // (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)`, // slug anywhere under appendix/ — a human files it into a category `(group)`,
// which leaves its route alone — and a new one lands at the guides/ root. A // which leaves its route alone — and a new one lands at the appendix/ root. A
// guide whose tag no page carries any more is left alone: it is hand-owned. // guide whose tag no page carries any more is left alone: it is hand-owned.
const guideTags = [...tags.values()] const guideTags = [...tags.values()]
.filter((tag) => !TOPIC_LANDINGS[tag.slug]) .filter((tag) => !TOPIC_LANDINGS[tag.slug])
.sort((a, b) => a.slug.localeCompare(b.slug)); .sort((a, b) => a.slug.localeCompare(b.slug));
const guideFiles = new Map<string, string>(); // slug -> path relative to DOCS 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 await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel);
for (const tag of guideTags) { for (const tag of guideTags) {
const rel = guideFiles.get(tag.slug) ?? `guides/${tag.slug}.mdx`; const rel = guideFiles.get(tag.slug) ?? `appendix/${tag.slug}.mdx`;
const file = Bun.file(join(DOCS, rel)); const file = Bun.file(join(DOCS, rel));
if ((await file.exists()) && file.size > 0) continue; if ((await file.exists()) && file.size > 0) continue;
await Bun.write(file, guidePage(tag)); await Bun.write(file, guidePage(tag));
@@ -785,7 +846,7 @@ for (const tag of guideTags) {
// and must not be another guide's. // and must not be another guide's.
const topicIcons = new Set(Object.values(TOPIC_ICONS)); const topicIcons = new Set(Object.values(TOPIC_ICONS));
const guideIcons = new Map<string, string>(); // icon -> the guide that took it first 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)) { for await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) {
const slug = basename(rel, ".mdx"); const slug = basename(rel, ".mdx");
const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0); const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0);
const page = await Bun.file(join(DOCS, rel)).text(); const page = await Bun.file(join(DOCS, rel)).text();
@@ -809,11 +870,11 @@ for (const [dir, phase] of phases) {
report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]); report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]);
} }
// Script-owned folder metas — one per live topic folder, plus guides/ — // Script-owned folder metas — one per live topic folder, plus appendix/ —
// rewritten only when they differ. // rewritten only when they differ.
const metas: [label: string, dir: string, meta: string][] = [ const metas: [label: string, dir: string, meta: string][] = [
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]), ...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
...(guideTags.length ? [["guides", "guides", GUIDES_META] as [string, string, string]] : []), ...(guideTags.length ? [["appendix", "appendix", APPENDIX_META] as [string, string, string]] : []),
]; ];
for (const [label, dir, meta] of metas) { for (const [label, dir, meta] of metas) {
const path = join(DOCS, dir, "meta.ts"); const path = join(DOCS, dir, "meta.ts");