mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
feat: refactored docs structure
This commit is contained in:
1 parent
d388b3000e
commit
9de25fecff
212 files changed
+641
-4169
No files matched your search
Vendored
-14
@@ -1,14 +0,0 @@
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* 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!));
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* 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] };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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));
|
||||
}
|
||||
Reference in new issue
Block a user