diff --git a/apps/docs/lib/releases.ts b/apps/docs/lib/releases.ts new file mode 100644 index 0000000..2294f77 --- /dev/null +++ b/apps/docs/lib/releases.ts @@ -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 ` 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 = //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 `/` 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 { + const headers: Record = { 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()); +} diff --git a/apps/docs/pages/changelog.astro b/apps/docs/pages/changelog.astro new file mode 100644 index 0000000..7865f28 --- /dev/null +++ b/apps/docs/pages/changelog.astro @@ -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 = { + 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."; +--- + + +
+

Changelog

+

{description}

+ +
    + { + releases.map((r, i) => ( +
  1. + {/* The rail: a line down the left edge, a ring at each release. */} + {i < releases.length - 1 && ( +
  2. + )) + } +
+
+
diff --git a/apps/docs/topic-landings.ts b/apps/docs/topic-landings.ts index e8310ab..aa71c6a 100644 --- a/apps/docs/topic-landings.ts +++ b/apps/docs/topic-landings.ts @@ -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/`; + * landing page, and every other tag's badge to its Appendix guide, `/appendix/`; * - `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/.mdx` for + * - `apps/cli/sync.ts` creates a placeholder `content/appendix/.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" }; }