feat: refactored docs structure

This commit is contained in:
Prad Nukala committed 2026-10-05 14:22:28 -04:00
1 parent 31a1fcfdbd
commit 6247915ae5
222 files changed
+6452 -729

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
// Islands and the Blume modules they import (blume/components/islands/
// base-path.ts) are bundled by Vite, which provides import.meta.env. Vite is
// Blume's dependency, not this workspace's, so its client types are not
// resolvable from here; this mirrors the slice of them (ImportMetaEnv) tsc needs.
interface ImportMeta {
readonly env: {
readonly [key: string]: unknown;
readonly BASE_URL: string;
readonly MODE: string;
readonly DEV: boolean;
readonly PROD: boolean;
readonly SSR: boolean;
};
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 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. The row's own `[docs]` link is not used: it is
* frozen at release time (a problem solved before the docs existed has
* none), while the page links each problem by its LC number to its page now.
*
* 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;
/** The LeetCode number, the key of the docs `/<num>` short links. */
lc: number;
/** `LC 57 · Insert Interval`. */
title: string;
set: string;
/** `easy` / `medium` / `hard`, the problem's `diff:*` label. */
difficulty: 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] = line.slice(1, -1).split("|").map((cell) => cell.trim());
const lc = title?.match(/^LC (\d+) · /u)?.[1];
if (!issue || !title || !lc || !set || !difficulty) continue;
problems.push({ issue: Number(issue.slice(1)), lc: Number(lc), title, set, difficulty });
}
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());
}
+90
View File
@@ -0,0 +1,90 @@
/**
* The progress page's data: GET /api/stats from the SRS Worker, fetched in the
* browser so `blume build` never depends on the API being up. API_BASE is the
* deployed Worker — do not inline it anywhere else.
*
* Four islands read this (SolvedRing, SubmissionHeatmap, RecentSolves,
* TopicBubbles). Each hydrates on its own, but they share this module, so the
* one in-flight request is shared too: the page asks the Worker once.
*
* Invariants: a malformed body is an error state, never a throw; the first
* frame is always the loading state, so server and client render identically.
*/
import { useEffect, useState } from "react";
const API_BASE = "https://srs-api.prdlk.workers.dev";
// ── /api/stats response shape (the fields these islands read) ────
export const DIFFICULTIES = ["easy", "medium", "hard"] as const;
export type Difficulty = (typeof DIFFICULTIES)[number];
export interface Stats {
generated: string;
/** Per difficulty, catalog size and how many entered the ladder. */
difficulty: { difficulty: Difficulty; total: number; done: number }[];
/** Problems whose most recent attempt failed — the sweep day's miss log. */
attempting: number;
topics: { issue: number; name: string; total: number; done: number; taught: boolean }[];
/** The ten most recently solved, newest first, dated by first pass (ET). */
recent: { lc: number; title: string; difficulty: Difficulty; date: string }[];
/**
* The 365 days ending today (ET), zeros included — always the current
* calendar year to date. Per difficulty: every logged attempt, and every
* first solve (the first-pass date `recent` uses).
*/
year: Day[];
}
export interface Day {
date: string;
passes: number;
attempts: Record<Difficulty, number>;
solved: Record<Difficulty, number>;
}
function isStats(value: unknown): value is Stats {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
Array.isArray(v.difficulty) &&
typeof v.attempting === "number" &&
Array.isArray(v.topics) &&
Array.isArray(v.recent) &&
Array.isArray(v.year)
);
}
// ── the shared request ───────────────────────────────────────────
export type Load = { phase: "loading" } | { phase: "error" } | { phase: "ready"; stats: Stats };
let request: Promise<Stats | null> | undefined;
/** The page's stats, fetched once however many islands ask. */
export function useStats(): Load {
const [load, setLoad] = useState<Load>({ phase: "loading" });
useEffect(() => {
if (!request) {
request = fetch(`${API_BASE}/api/stats`)
.then((res) => (res.ok ? res.json() : null))
.then((body: unknown) => (isStats(body) ? body : null))
.catch(() => null);
}
let live = true;
request.then((stats) => {
if (live) setLoad(stats ? { phase: "ready", stats } : { phase: "error" });
});
return () => {
live = false;
};
}, []);
return load;
}
/** "YYYY-MM-DD" → a UTC-midnight Date, so calendar math never drifts with the reader's timezone. */
export function utcDate(iso: string): Date {
const [y, m, d] = iso.split("-").map(Number);
return new Date(Date.UTC(y!, m! - 1, d!));
}
+129
View File
@@ -0,0 +1,129 @@
/**
* One colour per topic tag, unique across the site: the problem-page badge
* row (`components/ProblemBadges.astro`) paints each tag with it, so a tag
* reads the same on every page and no two tags share a colour.
*
* Fifty-odd hues cannot all be told apart, so uniqueness alone is not the
* goal: tags that share a problem page must be told apart, since that is
* where they are seen together. The palette is a fixed grid in OKLCH, a few
* lightness rows by `HUE_STEP` hues, and tags are coloured greedily, most-used
* first. Each tag takes the free palette colour farthest (OKLab distance)
* from every colour its co-tags already hold, ties broken by distance from
* every colour taken at all. The answer is a pure function of the pages'
* tags, so it is identical on every page of one build; a newly solved problem
* can shift colours in the next.
*
* Blume's Badge takes a custom colour only as hex and uses that one value in
* both themes, as text over a 12% tint of itself, so the rows sit in the
* narrow lightness band where that text holds ≥ 3:1 contrast on the light and
* the dark background alike; wider rows spread tags further but fail one theme.
*/
const LIGHTNESS = [0.55, 0.6];
const CHROMA = 0.15;
const HUE_STEP = 5;
interface Swatch {
hex: string;
lab: [number, number, number];
}
/**
* slug → `#rrggbb`, pairwise distinct, for every slug in `slugs`; `pages` is
* each problem page's tag slugs, the co-occurrence the spread is judged by.
*/
export function tagColors(slugs: Iterable<string>, pages: readonly (readonly string[])[]): Map<string, string> {
const universe = new Set(slugs);
const uses = new Map<string, number>();
const cotags = new Map<string, Set<string>>();
for (const page of pages) {
const on = page.filter((slug) => universe.has(slug));
for (const slug of on) {
uses.set(slug, (uses.get(slug) ?? 0) + 1);
const set = cotags.get(slug) ?? new Set<string>();
for (const other of on) if (other !== slug) set.add(other);
cotags.set(slug, set);
}
}
const order = [...universe].sort((a, b) => (uses.get(b) ?? 0) - (uses.get(a) ?? 0) || a.localeCompare(b));
const palette = swatches(order.length);
const free = new Set(palette.keys());
const chosen = new Map<string, Swatch>();
for (const slug of order) {
const near = [...(cotags.get(slug) ?? [])].flatMap((other) => chosen.get(other) ?? []);
const all = [...chosen.values()];
let best = -1;
let bestScore: [number, number] = [-1, -1];
for (const i of free) {
const score: [number, number] = [nearest(palette[i]!, near), nearest(palette[i]!, all)];
if (score[0] > bestScore[0] || (score[0] === bestScore[0] && score[1] > bestScore[1])) {
best = i;
bestScore = score;
}
}
free.delete(best);
chosen.set(slug, palette[best]!);
}
return new Map([...chosen].map(([slug, swatch]) => [slug, swatch.hex]));
}
/** Distance from `swatch` to the closest of `others`; Infinity when there are none. */
function nearest(swatch: Swatch, others: readonly Swatch[]): number {
let min = Number.POSITIVE_INFINITY;
for (const other of others) {
const d = Math.hypot(swatch.lab[0] - other.lab[0], swatch.lab[1] - other.lab[1], swatch.lab[2] - other.lab[2]);
if (d < min) min = d;
}
return min;
}
/**
* The palette: every `HUE_STEP` degrees in each `LIGHTNESS` row, the step
* shrunk when there are more tags than swatches. Duplicate hexes (possible
* only at a tiny step) are dropped, which keeps every assignment unique.
*/
function swatches(count: number): Swatch[] {
const step = Math.min(HUE_STEP, Math.max(1, Math.floor((360 * LIGHTNESS.length) / Math.max(count, 1))));
const seen = new Set<string>();
const out: Swatch[] = [];
for (const lightness of LIGHTNESS) {
for (let hue = 0; hue < 360; hue += step) {
const swatch = oklch(lightness, hue);
if (seen.has(swatch.hex)) continue;
seen.add(swatch.hex);
out.push(swatch);
}
}
return out;
}
/**
* `oklch(lightness CHROMA hue)` as sRGB hex plus its OKLab coordinates. Hues
* sRGB cannot reach at full chroma (cyans) give up chroma until they fit,
* rather than being clipped per channel, which would shift the hue.
*/
function oklch(lightness: number, hue: number): Swatch {
const rad = (hue * Math.PI) / 180;
for (let chroma = CHROMA; ; chroma -= 0.005) {
const a = chroma * Math.cos(rad);
const b = chroma * Math.sin(rad);
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
const rgb = [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614681 * s,
];
if (chroma > 0 && rgb.some((c) => c < 0 || c > 1)) continue;
const hex = rgb
.map((c) => {
const v = Math.min(1, Math.max(0, c));
const srgb = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
return Math.round(srgb * 255).toString(16).padStart(2, "0");
})
.join("");
return { hex: `#${hex}`, lab: [lightness, a, b] };
}
}
+7
View File
@@ -0,0 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/** shadcn's class combiner: conditional classes, later Tailwind utilities win. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}