feat(docs): add changelog page and switch tag guide paths to appendix

This commit is contained in:
Prad Nukala committed 2026-09-30 18:00:35 -04:00
1 parent c3c482d954
commit 45bd3f771d
3 files changed
+214 -3

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
/**
* The `/changelog` page's data: every timever release apps/cli/release-topics.ts
* published — one per finished topic and per finished `Review <N>` issue —
* read from the GitHub API at build time (pages/changelog.astro awaits it).
*
* One page, no per-release subpages: each release becomes one timeline card,
* so the fields come from the release itself rather than a staged content
* entry. The date is the timever tag (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — the
* instant the last required problem closed), never `published_at`, which the
* API cannot backdate and which reads the backfill day for every old release.
* The problems are the rows of the body's `### Closed …` tables, the shape
* release-topics.ts's render() writes: `| #issue | LC n · Name | set | diff |
* closed | [py](…) · [docs](…) |`. Only `### Closed …` sections are read, so
* a release published before open problems were dropped from the body still
* shows only what finished.
*
* Auth: GITHUB_TOKEN when set (deploy.yml passes it), else unauthenticated —
* one request per 100 releases fits the anonymous limit. A failed request
* fails the build: a changelog silently missing its releases is worse.
*/
const REPO = "prdlk/leetcode";
const MARKER = /<!-- release-topics: #(\d+) -->/u;
const TIMEVER = /^(\d{4})(\d{2})(\d{2})\.(\d{2})(\d{2})(\d{2})\.\d{9}$/u;
const KIND = /^(Topic|Review) \d+ — /u;
export interface ReleaseProblem {
issue: number;
/** `LC 57 · Insert Interval`. */
title: string;
set: string;
/** `easy` / `medium` / `hard`, the problem's `diff:*` label. */
difficulty: string;
/** The problem's `/<num>` docs short link, once it has a page. */
docs?: string;
}
export interface Release {
/** Topic or Review issue the release records. */
issue: number;
name: string;
kind: "Topic" | "Review";
/** UTC instant the last required problem closed, from the timever tag. */
finishedAt: Date;
url: string;
problems: ReleaseProblem[];
}
/** Rows of every `### Closed …` table in a release body. */
function closedProblems(body: string): ReleaseProblem[] {
const problems: ReleaseProblem[] = [];
let closed = false;
for (const line of body.split("\n")) {
if (line.startsWith("### ")) closed = line.startsWith("### Closed");
if (!closed || !line.startsWith("| #")) continue;
const [issue, title, set, difficulty, , solution] = line.slice(1, -1).split("|").map((cell) => cell.trim());
if (!issue || !title || !set || !difficulty) continue;
problems.push({
issue: Number(issue.slice(1)),
title,
set,
difficulty,
docs: solution?.match(/\[docs\]\(([^)]+)\)/u)?.[1],
});
}
return problems;
}
export async function readReleases(): Promise<Release[]> {
const headers: Record<string, string> = { accept: "application/vnd.github+json" };
if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
const releases: Release[] = [];
for (let page = 1; ; page++) {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`, { headers });
if (!res.ok) throw new Error(`GET ${REPO} releases -> ${res.status} ${await res.text()}`);
const batch = (await res.json()) as { tag_name: string; name: string | null; body: string | null; html_url: string }[];
for (const r of batch) {
const issue = r.body?.match(MARKER)?.[1];
const t = r.tag_name.match(TIMEVER);
const kind = r.name?.match(KIND)?.[1];
if (!issue || !t || !kind || !r.name || !r.body) continue;
releases.push({
issue: Number(issue),
name: r.name,
kind: kind as Release["kind"],
finishedAt: new Date(`${t[1]}-${t[2]}-${t[3]}T${t[4]}:${t[5]}:${t[6]}Z`),
url: r.html_url,
problems: closedProblems(r.body),
});
}
if (batch.length < 100) break;
}
return releases.sort((a, b) => b.finishedAt.getTime() - a.finishedAt.getTime());
}
+117
View File
@@ -0,0 +1,117 @@
---
/**
* `/changelog`: every topic and review release on one page, newest first — a
* vertical timeline, the date on the left and one card per release on the
* right, with no per-release subpages. Replaces Blume's generated changelog
* (a custom page wins its route); the header links here from
* `navigation.actions` in blume.config.ts. Data: lib/releases.ts.
*/
import PageLayout from "blume/components/layout/PageLayout.astro";
import data from "blume:data";
import { readReleases } from "../lib/releases.ts";
const { config } = data;
const releases = await readReleases();
const DATE = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
month: "short",
day: "numeric",
year: "numeric",
});
const issueUrl = (n: number) => `https://github.com/prdlk/leetcode/issues/${n}`;
// The /progress cards' difficulty palette (--chart-1..3 in theme.css), so a
// Hard reads the same colour here as in the SolvedRing gauge.
const DIFFICULTY: Record<string, { label: string; class: string }> = {
easy: { label: "Easy", class: "border-chart-1/40 bg-chart-1/10 text-chart-1" },
medium: { label: "Med.", class: "border-chart-2/40 bg-chart-2/10 text-chart-2" },
hard: { label: "Hard", class: "border-chart-3/40 bg-chart-3/10 text-chart-3" },
};
const description = "Every topic and review the campaign finished, newest first.";
---
<PageLayout
site={{ title: config.title, description: config.description }}
logo={config.logo}
favicon={config.favicon}
banner={config.banner}
analytics={config.analytics}
navigation={data.navigation}
fontCssVars={data.fontCssVars}
themeMode={config.theme.mode}
searchEnabled={config.search.enabled}
siteUrl={config.site}
ogEnabled={config.og.enabled}
structuredDataEnabled={config.structuredData}
page={{ title: `Changelog - ${config.title}`, description, route: "/changelog" }}
>
<section class="mx-auto w-full max-w-4xl px-6 py-16">
<h1 class="m-0 text-4xl font-semibold tracking-tight text-foreground">Changelog</h1>
<p class="mt-3 text-lg text-muted-foreground">{description}</p>
<ol class="relative mt-12 list-none p-0">
{
releases.map((r, i) => (
<li class="relative grid gap-4 pb-14 pl-8 sm:grid-cols-[11rem_1fr] sm:gap-8">
{/* The rail: a line down the left edge, a ring at each release. */}
{i < releases.length - 1 && (
<span class="absolute top-3 bottom-0 left-[5px] w-px bg-border" aria-hidden="true" />
)}
<span
class="absolute top-1.5 left-0 size-[11px] rounded-full border border-emerald-500 bg-background"
aria-hidden="true"
/>
<div class="font-mono text-sm">
<time class="block uppercase tracking-wide text-foreground" datetime={r.finishedAt.toISOString()}>
{DATE.format(r.finishedAt)}
</time>
<a class="mt-1 block text-xs text-muted-foreground no-underline hover:underline" href={r.url}>
{r.kind} release ↗
</a>
</div>
<article id={`issue-${r.issue}`} class="rounded-lg border border-border bg-muted/30 p-6">
<header class="flex items-baseline justify-between gap-4">
<h2 class="m-0 text-lg font-semibold text-foreground">{r.name}</h2>
<a
class="shrink-0 font-mono text-sm text-muted-foreground no-underline hover:underline"
href={issueUrl(r.issue)}
>
#{r.issue}
</a>
</header>
<ul class="mt-4 mb-0 list-none space-y-2 p-0">
{r.problems.map((p) => (
<li class="flex items-baseline gap-3 text-base leading-relaxed text-muted-foreground">
<span class="select-none text-border" aria-hidden="true">—</span>
<span class="min-w-0 flex-1">
<span class="mr-2 font-mono text-xs uppercase tracking-wide">{p.set}</span>
<a class="text-foreground no-underline hover:underline" href={issueUrl(p.issue)}>
{p.title}
</a>
{p.docs && (
<a class="ml-2 font-mono text-xs text-emerald-500 no-underline hover:underline" href={p.docs}>
view →
</a>
)}
</span>
{/* Its own right-hand column, one width for all three, so the
badges line up down the card instead of crowding the link. */}
{DIFFICULTY[p.difficulty] && (
<span
class:list={[
"w-12 shrink-0 rounded-full border py-px text-center font-mono text-[11px] leading-4",
DIFFICULTY[p.difficulty]!.class,
]}
>
{DIFFICULTY[p.difficulty]!.label}
</span>
)}
</li>
))}
</ul>
</article>
</li>
))
}
</ol>
</section>
</PageLayout>
+3 -3
View File
@@ -4,10 +4,10 @@
* tag", read by the sides that must agree:
*
* - `components/ProblemBadges.astro` links a covered tag's badge to its
* landing page, and every other tag's badge to `/guides/<slug>`;
* landing page, and every other tag's badge to its Appendix guide, `/appendix/<slug>`;
* - `components/BacklinksList.astro` lists, under a landing or guide page,
* every problem whose badge links there — both via `tagTarget()`;
* - `apps/cli/sync.ts` creates a placeholder `content/guides/<slug>.mdx` for
* - `apps/cli/sync.ts` creates a placeholder `content/appendix/<slug>.mdx` for
* every tag a problem page carries that is NOT a key here.
*
* Add a key when a campaign topic starts teaching a tag; its guide page is then
@@ -61,7 +61,7 @@ export function tagTarget(
const prefer = page.tags.some((t) => t.slug === "tree") ? "/tree-" : "/graph-";
const landing = landings.includes(own) ? own : (landings.find((p) => p.startsWith(prefer)) ?? landings[0]);
if (landing) return { href: landing, kind: "landing" };
const guide = `/guides/${tag.slug}`;
const guide = `/appendix/${tag.slug}`;
if (served.has(guide)) return { href: guide, kind: "guide" };
return { href: `https://leetcode.com/problem-list/${tag.slug}/`, kind: "external" };
}