Compare commits

..
Author SHA1 Message Date
Prad Nukala 66affbe128 feat(work): implement largestRectangleArea solution and fix formatting 2026-10-05 14:01:18 -04:00
Prad Nukala 9de25fecff feat: refactored docs structure 2026-10-05 13:53:14 -04:00
Prad Nukala d388b3000e feat(work): add new LeetCode solutions for median, largest rectangle, and simplify path 2026-10-05 13:51:47 -04:00
Prad Nukala f14dcf2723 docs(skills): update paths to docs directory in problem-approach skill 2026-10-05 13:51:38 -04:00
Prad Nukala d171fe1565 chore(config): remove obsolete docs scripts and .gitignore entries 2026-10-05 13:51:17 -04:00
Prad Nukala e9d40020f4 refactor(cli): generate docs/site.json manifest and update docs site references 2026-10-05 13:51:14 -04:00
Prad Nukala 0b004fcba2 ci(deploy): remove Pages build steps and limit permissions to worker deployment 2026-10-05 13:51:10 -04:00
Prad Nukala 33022c806d docs(docs): update problem page paths to use (problems)/<num>_<slug>.mdx format and adjust related examples 2026-10-05 13:12:48 -04:00
Prad Nukala d59b9dccd9 feat(work): add Find Peak Element solution (162) in Python 2026-10-05 13:08:59 -04:00
Prad Nukala 17c691c481 docs(redirects): add new redirects for problems 128, 162, and 643 2026-10-05 13:08:51 -04:00
Prad Nukala ea62cda955 feat(content): add new problem pages, reorganize docs into (problems) subfolders, and update meta display to page 2026-10-05 13:07:56 -04:00
Prad Nukala 74939c31d7 refactor(cli): adjust sync for (problems) group and page display meta 2026-10-05 13:07:52 -04:00
Prad Nukala 0d5c40ac4a style(docs): remove leading hyphens from AGENTS.md table rows 2026-10-05 12:34:59 -04:00
Prad Nukala 4a1838ab28 feat(work): add sliding window solution for maximum average subarray and implement longest consecutive sequence 2026-10-05 12:34:54 -04:00
Prad Nukala c2a375fffc feat(docs): add Appendix glossary and page for undocumented tags 2026-10-05 12:34:34 -04:00
Prad Nukala 0e9e06eb8d docs(content): revamp sliding-window examples and purge empty appendix pages 2026-10-05 12:34:30 -04:00
Prad Nukala 937266b629 feat(config): add appendix redirects and navigation entries, remove list schema, update comments 2026-10-05 12:34:27 -04:00
Prad Nukala 88c0c9b898 refactor(components): add appendix tag icons and drop list badge 2026-10-05 12:34:23 -04:00
Prad Nukala 099123539e refactor(cli): drop guide page creation and streamline appendix term handling in sync script 2026-10-05 12:34:17 -04:00
Prad Nukala 8c39b3ac23 feat(work): add solution for LeetCode 643 Maximum Average Subarray I 2026-10-05 11:24:01 -04:00
Prad Nukala 7ead021ee2 docs(docs): fix cursor handling and hover tip refactor in docs UI components 2026-10-05 11:23:57 -04:00
Prad Nukala 0d066b8104 chore(config): bump dependencies to latest versions 2026-10-05 11:23:52 -04:00
Prad Nukala 9af5ab1fbb docs(content): add YouTube embed to sliding window page 2026-10-05 11:01:17 -04:00
Prad Nukala f770408ea2 feat(work): add solution for LeetCode 1004 Max Consecutive Ones III 2026-10-02 17:19:55 -04:00
Prad Nukala cf6f09825a docs(docs): add redirect for LeetCode 1004 Max Consecutive Ones III page 2026-10-02 17:19:51 -04:00
Prad Nukala ab09e40a2e docs(content): add Max Consecutive Ones III page and update Array badge count 2026-10-02 17:19:34 -04:00
Prad Nukala 91dad73ebf feat(work): add solution for LeetCode 189 Rotate Array 2026-10-02 16:01:45 -04:00
Prad Nukala 400bd5abf5 docs(docs): add redirect for LeetCode 189 Rotate Array 2026-10-02 16:01:42 -04:00
Prad Nukala a258b46cf0 docs(content): add Rotate Array page and bump array/math badges 2026-10-02 16:01:39 -04:00
Prad Nukala 4875fd5139 feat(work): add solution for LeetCode 525 Contiguous Array 2026-10-02 13:03:24 -04:00
Prad Nukala 7b14673e95 docs(redirects): add redirect for Contiguous Array problem 2026-10-02 13:03:21 -04:00
Prad Nukala 1c45e03ddd docs(content): add Contiguous Array page and update array badge count 2026-10-02 13:03:18 -04:00
Prad Nukala cbc858b667 feat(work): add 290.word-pattern solution 2026-10-02 12:42:01 -04:00
Prad Nukala c890fd35a7 docs(redirects): add redirect for Word Pattern problem 2026-10-02 12:41:58 -04:00
Prad Nukala 3e62d6fa6f docs(content): add 290 Word Pattern page and fix String badge count 2026-10-02 12:41:52 -04:00
Prad Nukala 017f111594 docs(content): improve hash table page formatting and headings 2026-10-01 16:51:07 -04:00
265 changed files with 1852 additions and 6406 deletions

No files matched your search

+11 -9
View File
@@ -1,11 +1,11 @@
---
name: problem-approach
description: Write the `## Approach` section — a Mermaid flowchart that traces the submitted solution — for every docs solution page under apps/docs/content/ that lacks one, using the repo's exact placement and node conventions. Use when asked to add/implement/fill in missing approaches, draw the approach diagram for a problem page, or when `/problem-approach` is run.
description: Write the `## Approach` section — a Mermaid flowchart that traces the submitted solution — for every docs solution page under docs/ that lacks one, using the repo's exact placement and node conventions. Use when asked to add/implement/fill in missing approaches, draw the approach diagram for a problem page, or when `/problem-approach` is run.
---
# Problem approach diagrams
Every solution page (`apps/docs/content/(<phase>)/<topic>/<num>-<slug>.mdx`) has a
Every solution page (`docs/(<phase>)/<topic>/(problems)/<num>_<slug>.mdx`) has a
hand-owned section before the script-owned one:
```
@@ -23,7 +23,7 @@ next `work/` push.
1. **Find the pages** (numbered pages with no `## Approach` heading):
```sh
grep -L '^## Approach' apps/docs/content/*/*.mdx
grep -rL '^## Approach' docs --include='[0-9]*_*.mdx'
```
2. **For each page**, read `## Solution` and write the diagram from *that code* — the
@@ -47,11 +47,13 @@ next `work/` push.
````
4. **Check** each diagram by hand against the syntax rules below (the docs build does
not parse Mermaid — a broken diagram fails only in the browser), then run the
docs build once at the end and review the pages in `bun run dev`:
not parse Mermaid — a broken diagram fails only in the browser). The site is built
from prdlk/cs, which reads `docs/` over HTTP, so preview unpushed pages there
against this checkout (its `README` names the local-content switch), then build
once at the end:
```sh
cd apps/docs && bunx blume build --isolated && rm -rf .blume-verify
cd ../cs && bunx blume build --isolated && rm -rf .blume-verify
```
Pages with two languages (`<CodeGroup>`) get **one** diagram, traced from the
@@ -59,7 +61,7 @@ Python version. Pages with only JavaScript trace the JavaScript.
## Diagram conventions
Reference page: `apps/docs/content/(array)/15-3sum.mdx`. Every existing diagram
Reference page: `docs/(01-linear-structures)/two-pointers/(problems)/15_3sum.mdx`. Every existing diagram
follows this shape:
```mermaid
@@ -100,7 +102,7 @@ flowchart TD
The base case is the first decision after entry.
- **Classes** (design problems) use one `subgraph name["method(args)"]` per public
method, plus a `state["…"]` subgraph describing the fields, with `state --> method`
edges — see `(hash-table)/146-lru-cache.mdx`. Private helpers are named inside the
edges — see `hybrid-structures/(problems)/146_lru-cache.mdx`. Private helpers are named inside the
method nodes (`"_remove_node then _add_node — move it beside the tail"`), not drawn.
- **Annotate the why with an em dash** inside the node, sparingly (one clause, a few
per diagram): `"nums.sort() — duplicates become adjacent"`, `"map = {0: 1} — the
@@ -122,7 +124,7 @@ flowchart TD
### Code that does not run
Trace it anyway — the diagram documents what was submitted — and put a `::::warning`
callout **before** the fence naming exactly why it fails (`(array)/121-best-time-to-buy-and-sell-stock.mdx`):
callout **before** the fence naming exactly why it fails (`sliding-window/(problems)/121_best-time-to-buy-and-sell-stock.mdx`):
````md
## Approach
+4 -38
View File
@@ -5,53 +5,19 @@ on:
branches: [main]
workflow_dispatch:
# The Worker only. The docs site is built and published from prdlk/cs, which
# reads docs/ from this repo over HTTP (Blume's mdxRemote()), so this repo has
# no Pages build.
permissions:
contents: read
pages: write
id-token: write
# The Worker deploy is idempotent (wrangler uploads the whole bundle), so on a
# burst of pushes only the last one matters: cancel-in-progress is correct for
# both the Pages build and the Worker job.
# burst of pushes only the last one matters: cancel-in-progress is correct.
concurrency:
group: deploy
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22 # Blume requires 22.12+
- uses: oven-sh/setup-bun@v2 # skip if you use npm/pnpm
- run: bun install --frozen-lockfile
# pages/changelog.astro reads this repo's GitHub releases at build time
# (apps/docs/lib/releases.ts). The token authenticates that fetch —
# required if the repo is private, and it lifts the shared runner's
# unauthenticated API rate limit. `contents: read` covers it.
- run: bun run build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-pages-artifact@v3
with:
path: apps/docs/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
# Deploys on every main push (not path-filtered): apps/api/data/schedule.json
# is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit),
-3
View File
@@ -37,9 +37,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
/CLAUDE.md
.blume/
/apps/docs/docs/.wrangler/state/v3
.blume-verify/
# api worker
apps/api/.dev.vars
+14 -15
View File
@@ -2,7 +2,7 @@
## Project Overview
LeetCode interview-prep campaign (2026-08-17 → Nov 3: eight weeks of topics, then the Review-issue phase; the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
LeetCode interview-prep campaign (2026-08-17 → Nov 3: eight weeks of topics, then the Review-issue phase; the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) run as a repo: solution files under `work/`, the docs content under `docs/` (MDX only — the Blume site that publishes it lives in [prdlk/cs](https://github.com/prdlk/cs) and reads `docs/` over HTTP), GitHub Actions that reconcile issues, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
## Architecture & Data Flow
@@ -10,7 +10,8 @@ Three owners, one direction of truth:
```mermaid
flowchart LR
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
work[work/ solutions] -->|bun run sync| docs[docs/*.mdx + docs/site.json]
docs -->|mdxRemote + site.json fetch| Site[prdlk/cs docs site]
work -->|close-solved.yml| PI[problem issues]
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
PI -->|close-topics.yml| TI[topic + Review N issues]
@@ -38,7 +39,7 @@ flowchart LR
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is ONE custom page, `apps/docs/pages/changelog.astro` (a NeonUI-style timeline: date left, a card per release right, no per-release subpages, so nothing of it enters the sidebar or prev/next), linked from the header by `navigation.actions` (not a tab). `apps/docs/lib/releases.ts` fetches the releases at build (`GITHUB_TOKEN` in `deploy.yml`; a failed fetch fails the build), dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are the page's contract with `render()`. There is no changelog RSS feed.
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs site's `/changelog` (prdlk/cs `pages/changelog.astro` + `lib/releases.ts`) fetches this repo's releases at its build, dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are its cross-repo contract with `render()`.
- **Review issues re-parent problems; topics still own them.** The transfer phase's 20 `Review <N> — <Phase>` issues (label `review`, milestone `Review — Transfer & Mocks`; not the Worker's `Review — Week N` gate issues) hold open optional/deferred problems as sub-issues in solve order, moved there with `replace_parent`. `apps/api/src/reviews.ts` is the ONE recovery of ownership — `readReviews()` (one GraphQL request per 50 `review` issues, cheap enough for the Worker's cron subrequest budget) reads each moved problem's topic off its last `parent_issue_removed` event naming a topic, `movedFromTopics()` groups them — shared by the Worker's catalog reconcile (a moved problem's D1 row keeps syncing its labels and `topic_issue`) and `apps/cli/topics.ts` (`readProblems()` returns a topic's sub-issues plus its moved problems, so the finished rule and existing release bodies do not change when a problem moves). A Review issue finishes by `completion()` in `topics.ts` — every sub-issue closed, any set — which `close-topics.ts` closes it on and `release-topics.ts` releases it on, exactly like a topic: timever of the last close, same `<!-- release-topics: #N -->` marker (issue numbers never collide), body fields from the review's `**Scheduled:**`/`**Phase:**`/`**Load:**` lines.
- **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only, Oct 1 → Nov 3 — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it).
@@ -48,10 +49,10 @@ flowchart LR
|---|---|
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment (`/tree-dfs/invert-binary-tree`), the `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its Appendix guide page `/appendix/<slug>` (outlined; LeetCode's problem list if the guide is gone, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows in the sidebar (read off `data.navigation`: the topic `meta.ts` icon, the guide's `icon:`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a page so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing and guide page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components (so badges and backlinks cannot disagree) and the map by sync. `content/appendix/` (the sidebar's `Appendix` row, right under Progress: `order: 0` ahead of the phase groups, `display: "page"` so it drills into its own panel — no header tab) is one page per tag outside that map, filed by hand into `(group)` categories — `(1-data-structures)/`, `(2-algorithms)/`, `(3-techniques)/` (hand-written `meta.ts` each; no route segment, so every guide stays `/appendix/<slug>`). Sync finds a guide by slug anywhere under `appendix/` and creates a placeholder only when none exists or it is empty — at the `appendix/` root, for you to file into a category and give a Lucide `icon:` — the one every badge linking to it shows — that no topic and no other guide uses (sync warns while it is missing, a `TOPIC_ICONS` value, or another guide's) — and after that owns exactly one key of it: `sidebar.badge`, the sidebar count, rewritten every run to the guide's backlink count (problem pages carrying the tag — equal to the `BacklinksList` rows, since a guide's tag has no landing; dropped at zero). Everything else is hand-owned (a guide whose tag no page carries any more is left alone, badge dropped), with frontmatter `list:` — the tag's LeetCode problem list, rendered by the same wrapper as an orange `LeetCode list` badge; sync also writes `appendix/meta.ts`. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; the five `/progress` cards `SolvedRing`/`SubmissionHeatmap`/`SubmissionChart`/`TopicBubbles`/`RecentSolves`, and `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigation.json` reports for the topic folder, inert until a topic has pages), `components/<Name>.astro` registered in `components.ts` is static markup plus a custom element for behaviour, the way Blume's own `Frame` is built (`Carousel`/`CarouselImage`: `src` takes a URL, `/file.png`, or the page-relative `../../public/file.png`) |
| `docs/` | The docs content, and nothing of the site: no `blume.config.ts`, no `meta.ts`, no components. prdlk/cs publishes it with Blume's `mdxRemote()` source, which fetches `.md`/`.mdx` files only. `docs/(0N-<phase>)/<topic>/(problems)/<num>_<slug>.mdx` is generated by sync; `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is a topic's hand-written landing page, and `index.mdx`/`keywords.mdx`/`progress.mdx` sit at the root. The phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment, and `(problems)` is a second `(group)` inside it, so `tree-dfs/(problems)/226_invert-binary-tree.mdx` routes to `/tree-dfs/invert-binary-tree` (Blume sorts by the `<num>_` prefix and strips it from the route). `docs/site.json` is sync's generated manifest and the site's ONE other input: `folders` (path under `docs/` → `{ title, order, icon?, display? }`, exactly what a `meta.ts` in that folder would say — every topic `display: "page"`, so it is one sidebar row whose panel holds its landing page and a "Problems" section) and `redirects` (the `/<num>` short links). Images are root-absolute (`![](/topo_intro_1.png)`, `<CarouselImage src='/binary_search.001.png' />`) and served from the site's `public/` — a page-relative path cannot resolve once the MDX is fetched over HTTP. Frontmatter keys beyond Blume's (`topics:`, `video:`, `issue:`) and the MDX components pages use (`<CodeGroup>`, `<Carousel>`/`<CarouselImage>`, the islands) are the site's to declare — add a new one there before a page uses it |
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
| `.github/workflows/` | deploy (docs → Pages with `GITHUB_TOKEN` for the releases-backed changelog, Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
| `.githooks/` | Tracked git hooks; `bun install` points `core.hooksPath` here (root `prepare` script, no husky). `pre-commit` runs `bun run sync` and stages only what sync changed (pages, `apps/docs/redirects.ts`), so a solution, its page and its `/<num>` short link land in one commit; it refuses to run while `work/1` has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under `apps/docs/content` (in-progress `## Approach` prose) unstaged. `git commit --no-verify` for a deliberate partial commit |
| `.github/workflows/` | deploy (Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push; the docs site deploys from prdlk/cs), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
| `.githooks/` | `bun install` points `core.hooksPath` here (root `prepare` script, no husky). Its `pre-commit` hook (run `bun run sync`, stage only what sync changed — pages and `docs/site.json`) was removed in `e63d517`, so nothing runs on commit today: run `bun run sync` yourself before committing a solution, or its page and short link ship a commit later |
## Development Commands
@@ -71,12 +72,11 @@ bun run test # run ONE solution against LeetCode's judge (not a test
# most recently tested first, stamped in the db on exit 0)
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
# hidden; last_submitted stamped in the db on exit 0)
bun run sync # work/1 → apps/docs/content/ pages + apps/docs/redirects.ts (/<num> short
bun run sync # work/1 → docs/ pages + docs/site.json (folder meta + /<num> short
# links; re-solves are never published)
bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page
# (channel search scraped, no API key; --write adds the
# `video:` key; "no NeetCode match" pages stay untouched)
bun run dev|build # Blume docs site (runs in apps/docs/)
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
bun run close-topics -- --dry-run
bun run release-topics -- --dry-run # prints every would-be topic release body
@@ -113,21 +113,20 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`. The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `docs/(0N-<phase>)/<topic>/`, with every problem page in the topic's `(problems)/` group as `<num>_<slug>.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). `docs/site.json` is wholly script-owned and rewritten whenever it differs, because `mdxRemote()` reads no `meta.ts`: its `folders` are what a `meta.ts` per phase and topic folder would carry — a phase `N. Name` at `order: N`, a topic its exact label, curriculum order, `TOPIC_ICONS` icon and `display: "page"` — all derived from the README, so a rename there is a rename on the site; its `redirects` are the `/<num>` → `/<topic>/<slug>` short links. Which tags the site teaches as topics (`topic-landings.ts`) and its Appendix glossary (`appendix.ts`) are the site's, in prdlk/cs: sync no longer checks them. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing through an emptied `(problems)` and topic into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` / `apps/docs/lib/stats.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs `/progress` islands and `bun run stats`, so its shape is their contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The docs `/progress` page is five LeetCode-style islands — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many islands hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively — Blume's Vite has no `@/` alias; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
- `apps/docs/blume.config.ts` — site base `/leetcode`; imports `apps/docs/redirects.ts`, sync-generated (`/<num>` → `/<topic>/<slug>`, one row per page, exact root-relative paths — Blume adds the base and, for the static Pages build, emits a meta-refresh page per rule plus `blume-redirects.json`); `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`).
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs site's `/progress` islands (prdlk/cs `lib/stats.ts`) and `bun run stats`, so its shape is a cross-repo contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`). `/api/stats` and `/ask` reflect a requesting origin only when it is in the `DOCS_ORIGIN` var (`apps/api/wrangler.jsonc`, comma-separated: `https://lc.prd.lk,https://prdlk.github.io`) — a new host for the docs site must be added there.
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
## Runtime/Tooling Preferences
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`. Node 22 appears only in `deploy.yml` because Blume requires it.
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. Two exceptions, each confined to one surface. The digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. And the docs `/progress` islands: shadcn/ui (`recharts`, `radix-ui`, `class-variance-authority`, `clsx`, `tailwind-merge`) plus `d3-hierarchy` for the bubble packing, client-side in `apps/docs` only. Do not extend either exception to any other module.
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`.
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. One exception, confined to one surface: the digest email, `apps/api/src/email.tsx`, renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend it to any other module. (The docs site's own dependencies are prdlk/cs's.)
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
## Testing & QA
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
- Docs changes: `bunx blume build --isolated` (from `apps/docs/`) must pass with no new warnings (then `rm -rf apps/docs/.blume-verify`); after sync, review only *created* pages (prose transforms are heuristic).
- Docs changes: the site builds in prdlk/cs, which must pass `bunx blume build --isolated` with no new warnings against this repo's `docs/`; after sync, review only *created* pages (prose transforms are heuristic) and `docs/site.json`'s diff.
+4 -4
View File
@@ -21,14 +21,14 @@
* 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 docs site (prdlk/cs `lib/releases.ts`) reads these `### Closed …`
* tables back for its `/changelog` timeline, so their row shape is its contract.
*
* The tag is annotated and backdated (tagger date = finish), so
* `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
* (the API has no field for it), so the docs changelog dates each entry from
* this timever tag instead (apps/docs/lib/releases.ts)
* this timever tag instead (prdlk/cs `lib/releases.ts`)
* — the tag format is that contract.
*
* Reconciles instead of reacting: every finished topic or review without a
@@ -59,7 +59,7 @@ const DRY =
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
/** The docs site (apps/docs/blume.config.ts `deployment.site`); sync publishes `/<num>` short links. */
/** The docs site (prdlk/cs `blume.config.ts` `deployment.site`); it publishes sync's `/<num>` short links. */
const DOCS_SITE = "https://lc.prd.lk";
const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668";
+84 -218
View File
@@ -1,6 +1,13 @@
#!/usr/bin/env bun
/**
* Sync work/1 leetcode solutions into apps/docs/content/ pages.
* Sync work/1 leetcode solutions into docs/ pages, plus docs/site.json.
*
* docs/ is content only — MDX pages and one generated JSON manifest, no Blume
* config. The docs site itself (config, components, islands, the Appendix)
* lives in prdlk/cs and reads docs/ over HTTP with Blume's `mdxRemote()`
* source, which fetches `.md`/`.mdx` and nothing else: no `meta.ts`. So the
* sidebar meta a folder `meta.ts` used to carry, and the `/<num>` short links,
* travel in `docs/site.json` instead, which the site fetches at the same ref.
*
* - New problems get a full page (frontmatter, warning, examples,
* constraints, solution) in the gold-standard format.
@@ -15,9 +22,9 @@
* `topicTags` once — when the page is created, or on any later run that
* finds the key missing — and never refreshed after, so a sync with nothing
* new makes no request. Delete the key to refetch it. The page itself
* renders no badges: the docs MDX `wrapper`
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
* `video:` key `videos.ts` writes into the badge row under the description.
* renders no badges: the site's MDX `wrapper` (prdlk/cs
* `components/ProblemBadges.astro`) turns `topics:` and the `video:` key
* `videos.ts` writes into the badge row under the description.
* - Every planned page (a problem the README plan lists) carries its GitHub
* problem issue as frontmatter `issue:` (the URL of the `problem`-labelled
* issue titled `LC <num> · …`), rendered by the same wrapper as a GitHub
@@ -27,54 +34,48 @@
* (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
* `apps/docs/topic-landings.ts`) gets a placeholder guide page in the
* Appendix, `appendix/<slug>.mdx`, with `list:` pointing at LeetCode's
* problem list for the tag. Created once (a missing or empty file), then
* hand-owned except for `sidebar.badge`, which sync keeps at the guide's
* backlink count (the problem pages carrying the tag; dropped at zero); the
* tag's badge on a problem page links to it. A guide may be filed into a
* category `(group)` folder (e.g. `appendix/(2-algorithms)/`) — no route
* segment, so `/appendix/<slug>` holds — and is found there by slug; a new
* placeholder lands at the `appendix/` root for a human to file (and give
* an `icon:`, which every badge linking to the guide shows; sync warns
* while one is missing or shared).
*
* 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
* plan (warm-ups) has no topic, so it groups under the data-structure CATEGORY
* its file sits under in `work/1/<Difficulty>/<Category>/` instead.
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`).
* A problem outside the plan (warm-ups) has no topic, so it groups under the
* data-structure CATEGORY its file sits under in
* `work/1/<Difficulty>/<Category>/` instead.
*
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
* The phase folder is a Blume `(group)` — a sidebar section adding no route
* segment — while the topic folder is a real segment
* (`/tree-dfs/invert-binary-tree`), so a hand-written `index.mdx` in it is the
* topic's landing page at `/tree-dfs` and Blume links the sidebar group row to
* it. Sync never writes or moves an `index.mdx`, and never removes a folder
* that still holds one. A phase folder's `meta.ts` is written once, when
* missing, then hand-owned (its title is yours to rename); a topic's is
* script-owned. An unplanned category group has no phase and sits at the
* content root.
* it. The topic's problem pages sit one level down in a `(problems)` group,
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either,
* and Blume strips the `<num>_` prefix from the route while sorting by it, so
* the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves
* an `index.mdx`, and never removes a folder that still holds one. An
* unplanned category group has no phase and sits at the docs/ root.
*
* `docs/site.json` is wholly script-owned and rewritten whenever it differs:
* - `folders`: Blume folder meta keyed by folder path under docs/ — exactly
* the key a `meta.ts` in that folder would have. A phase is `N. Name` in
* phase order; a topic is its exact name (the folder slug loses `/` and
* case), curriculum order, `TOPIC_ICONS` icon and `display: "page"`, so it
* is one sidebar row drilling into its own panel. `(problems)` has none:
* Blume's humanized "Problems" is the label.
* - `redirects`: one `{ from: "/<num>", to: "/<topic>/<slug>" }` per page, in
* number order — exact root-relative paths; Blume prepends the deployment
* base itself.
*
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
* of problems whose page already exists (see work.ts), and a fresh scaffold
* there would otherwise wipe the page's `## Solution`.
*
* Every page also gets a short link: `/<num>` → `/<topic>/<slug>` (so
* `/leetcode/217` opens Contains Duplicate). The rules live in the generated
* `apps/docs/redirects.ts`, which `blume.config.ts` imports — Blume takes exact
* root-relative paths and prepends the deployment base itself.
*/
import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
import { mkdir, readdir, rename, rmdir } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
import { github } from "./github.ts";
const ROOT = join(import.meta.dir, "..", "..");
const WORK = join(ROOT, "work", "1");
const DOCS = join(ROOT, "apps", "docs", "content");
const REDIRECTS = join(ROOT, "apps", "docs", "redirects.ts");
const DOCS = join(ROOT, "docs");
const MANIFEST = join(DOCS, "site.json");
type Lang = "js" | "py";
@@ -328,31 +329,34 @@ function phaseDir(phase: Phase): string {
return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`;
}
/**
* Blume `(group)` meta for a phase: the overview page's heading, in phase
* order. Written only when the folder has none — the title is hand-owned after
* that. Nothing is lost by never rewriting it: the order and name it is built
* from are also the folder name (`phaseDir`), so a README change to either
* lands in a new folder that gets a fresh meta.
*/
function phaseMeta(phase: Phase): string {
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(`Phase ${phase.order}: ${phase.name}`)}, order: ${phase.order} });\n`;
/** A sidebar group's Blume folder meta, as `docs/site.json` carries it. */
interface FolderMeta {
title: string;
order: number;
icon?: string;
display?: "page";
}
/**
* Blume folder meta for a topic: exact label (the folder slug loses `/` and
* case), curriculum order, the sidebar icon, and `display: "group"` so the
* topic collapses under its phase heading. An unplanned category group
* (warm-ups) has no README number and sorts at 0, ahead of phase 1, so the
* sidebar opens on the basics.
* Folder meta for a phase `(group)`: the overview page's heading, `N. Name`,
* in phase order. Derived like everything in the manifest, so a README rename
* renames the heading — and lands in a new folder (`phaseDir`) anyway.
*/
function groupMeta(topic: Topic): string {
function phaseMeta(phase: Phase): FolderMeta {
return { title: `${phase.order}. ${phase.name}`, order: phase.order };
}
/**
* Folder meta for a topic: exact label, curriculum order, the sidebar icon,
* and `display: "page"` so the topic is one row under its phase heading that
* drills into its own panel (the landing page, then its `(problems)` list).
* An unplanned category group (warm-ups) has no README number and sorts at 0,
* ahead of phase 1, so the sidebar opens on the basics.
*/
function groupMeta(topic: Topic): FolderMeta {
const icon = TOPIC_ICONS[topic.name];
if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`);
const fields = [`title: ${JSON.stringify(topic.name)}`, `order: ${topic.order ?? 0}`];
if (icon) fields.push(`icon: ${JSON.stringify(icon)}`);
fields.push(`display: "group"`);
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ ${fields.join(", ")} });\n`;
return { title: topic.name, order: topic.order ?? 0, ...(icon ? { icon } : {}), display: "page" };
}
// ── LeetCode topic tags ──────────────────────────────────────────
@@ -399,14 +403,6 @@ function topicsYaml(tags: TopicTag[]): string {
return ["topics:", ...tags.map((t) => ` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`)].join("\n");
}
/** The frontmatter's `topics:` as sync wrote them (or a human edited them); [] when absent. */
function frontmatterTopics(page: string): TopicTag[] {
const fm = page.match(/^---\n([\s\S]*?)\n---/)?.[1];
const data: unknown = fm ? Bun.YAML.parse(fm) : undefined;
if (!data || typeof data !== "object" || !("topics" in data) || !Array.isArray(data.topics)) return [];
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;
@@ -452,56 +448,6 @@ async function issueUrl(sol: Solution): Promise<string | undefined> {
return url;
}
// ── guides ───────────────────────────────────────────────────────
/**
* 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,
* rendered as a badge by the docs MDX `wrapper`. Written once — the page
* is hand-owned from then on.
*/
function guidePage(tag: TopicTag): string {
return [
"---",
`title: ${JSON.stringify(tag.name)}`,
`list: ${JSON.stringify(`https://leetcode.com/problem-list/${tag.slug}/`)}`,
"---",
"",
":::note",
`This guide has not been written yet. The LeetCode list above has every problem tagged ${tag.name}.`,
":::",
"",
].join("\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
* one key sync owns on a hand-owned guide — or dropped (with a `sidebar:` left
* empty) at zero, so the sidebar shows a count only where there is something
* to count.
*/
function withBacklinkBadge(page: string, count: number): string {
const fm = page.match(/^---\n[\s\S]*?\n---/);
if (!fm) return page;
let head = fm[0].replace(/^ {2}badge: .*\n/m, "");
if (count > 0) {
const line = ` badge: '${count}'`;
head = /^sidebar:$/m.test(head)
? head.replace(/^sidebar:$/m, `sidebar:\n${line}`)
: head.replace(/\n---$/, `\nsidebar:\n${line}\n---`);
} else {
head = head.replace(/^sidebar:\n(?! {2})/m, "");
}
return head + page.slice(fm[0].length);
}
// ── inline-code formatting heuristics ────────────────────────────
/** Identifiers worth backticking in prose, harvested from the code. */
@@ -751,28 +697,24 @@ for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).s
const report: [string, string, string][] = [];
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
const groups = new Map<string, Topic>(); // topic folder -> topic, for meta.ts
const phases = new Map<string, Phase>(); // phase folder -> phase, for meta.ts
const folders = new Map<string, FolderMeta>(); // folder under docs/ -> its sidebar meta, for site.json
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order
const tags = new Map<string, TopicTag>(); // every tag slug any problem page carries -> tag
const tagged = new Map<string, number>(); // tag slug -> how many problem pages carry it
const redirects: { from: string; to: string }[] = []; // one per page, numeric order
for (const sol of ordered) {
const langs = Object.keys(sol.code).sort().join("+");
const workRef = `${sol.num}.${sol.slug} (${langs})`;
// (0N-<phase>)/<topic>/ folder (the topic is a real route segment, so its
// index.mdx is the landing page) + leetcode-number prefix: numeric sidebar
// order, prefix stripped from the URL.
// (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment,
// so its index.mdx is the landing page; `(problems)` adds none) +
// leetcode-number prefix: numeric sidebar order, prefix stripped from the URL.
const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category };
const dir = topicDir(topic);
groups.set(dir, topic);
if (topic.phase) phases.set(phaseDir(topic.phase), topic.phase);
const name = `${dir}/${sol.num}-${sol.slug}.mdx`;
if (!folders.has(dir)) folders.set(dir, groupMeta(topic));
if (topic.phase) folders.set(phaseDir(topic.phase), phaseMeta(topic.phase));
const name = `${dir}/(problems)/${sol.num}_${sol.slug}.mdx`;
const existing = pages.get(sol.num);
await mkdir(join(DOCS, dir), { recursive: true });
await mkdir(join(DOCS, dirname(name)), { recursive: true });
let written: string; // the page as it stands after this run, for its topics
if (existing) {
const page = await Bun.file(join(DOCS, existing)).text();
const at = page.indexOf("## Solution");
@@ -794,117 +736,41 @@ for (const sol of ordered) {
actions.push("updated");
}
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
written = next;
} else {
const page = renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol));
await Bun.write(join(DOCS, name), page);
await Bun.write(join(DOCS, name), renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol)));
report.push([workRef, name, "created"]);
written = page;
}
for (const tag of frontmatterTopics(written)) {
tags.set(tag.slug, tag);
tagged.set(tag.slug, (tagged.get(tag.slug) ?? 0) + 1);
}
// The route drops the phase `(group)` and the page's number prefix.
redirects.push(` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`);
// The route drops the phase `(group)`, `(problems)` and the number prefix.
redirects.push({ from: `/${sol.num}`, to: `/${slugify(topic.name)}/${sol.slug}` });
}
// Script-owned short links, one per page; imported by blume.config.ts.
// The site's one generated input besides the pages; script-owned, rewritten
// only when it differs. Folders sorted by path so a diff reads by location.
{
const module = [
"// Generated by `bun run sync` (apps/cli/sync.ts) — do not edit.",
"// `/leetcode/<num>` → that problem's page; exact paths, base added by Blume.",
"export default [",
...redirects,
"];",
"",
].join("\n");
if (!(await Bun.file(REDIRECTS).exists()) || (await Bun.file(REDIRECTS).text()) !== module) {
await Bun.write(REDIRECTS, module);
report.push([`${redirects.length} redirects`, "apps/docs/redirects.ts", "written"]);
const manifest = `${JSON.stringify(
{ folders: Object.fromEntries([...folders].sort(([a], [b]) => (a < b ? -1 : 1))), redirects },
null,
2,
)}\n`;
if (!(await Bun.file(MANIFEST).exists()) || (await Bun.file(MANIFEST).text()) !== manifest) {
await Bun.write(MANIFEST, manifest);
report.push([`${folders.size} folders, ${redirects.length} redirects`, "docs/site.json", "written"]);
}
}
// 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
// slug anywhere under appendix/ — a human files it into a category `(group)`,
// 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.
const guideTags = [...tags.values()]
.filter((tag) => !TOPIC_LANDINGS[tag.slug])
.sort((a, b) => a.slug.localeCompare(b.slug));
const guideFiles = new Map<string, string>(); // slug -> path relative to DOCS
for await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel);
for (const tag of guideTags) {
const rel = guideFiles.get(tag.slug) ?? `appendix/${tag.slug}.mdx`;
const file = Bun.file(join(DOCS, rel));
if ((await file.exists()) && file.size > 0) continue;
await Bun.write(file, guidePage(tag));
report.push([`tag ${tag.slug}`, rel, "created"]);
}
// Every guide's sidebar badge is its backlink count: the problem pages whose
// tag badge links to it, which is every page carrying the tag, since a guide's
// tag has no landing (`tagTarget()`). A guide whose tag a landing has since
// claimed has no backlinks left. Counts move with every solved problem, so the
// badge is rewritten whenever it differs; the rest of the guide is untouched.
// The same pass checks each guide's hand-picked `icon:`, which is also the icon
// on every problem-page badge linking to the guide: it must exist, must not be
// a topic group's (`TOPIC_ICONS`), so a guide badge never reads as a topic's,
// and must not be another guide's.
const topicIcons = new Set(Object.values(TOPIC_ICONS));
const guideIcons = new Map<string, string>(); // icon -> the guide that took it first
for await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) {
const slug = basename(rel, ".mdx");
const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0);
const page = await Bun.file(join(DOCS, rel)).text();
const icon = page.match(/^---\n[\s\S]*?\n---/)?.[0].match(/^icon: (\S+)$/m)?.[1];
if (!icon) console.warn(`${rel}: no icon; its badges render without one — file it and add \`icon:\``);
else if (topicIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is a topic's (TOPIC_ICONS); pick another`);
else if (guideIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is ${guideIcons.get(icon)}'s too; pick another`);
else guideIcons.set(icon, rel);
const next = withBacklinkBadge(page, count);
if (next === page) continue;
await Bun.write(join(DOCS, rel), next);
report.push([`${count} backlinks`, rel, "badged"]);
}
// Phase metas: created when missing (an empty file counts as missing), never
// rewritten, so a renamed phase title survives every sync.
for (const [dir, phase] of phases) {
const file = Bun.file(join(DOCS, dir, "meta.ts"));
if ((await file.exists()) && file.size > 0) continue;
await Bun.write(file, phaseMeta(phase));
report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]);
}
// Script-owned folder metas — one per live topic folder, plus appendix/ —
// rewritten only when they differ.
const metas: [label: string, dir: string, meta: string][] = [
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
...(guideTags.length ? [["appendix", "appendix", APPENDIX_META] as [string, string, string]] : []),
];
for (const [label, dir, meta] of metas) {
const path = join(DOCS, dir, "meta.ts");
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta) continue;
await Bun.write(path, meta);
report.push([label, `${dir}/meta.ts`, "written"]);
}
// A folder every page left behind is dead: drop its meta and the folder itself,
// then the phase folder above it if that emptied too. A folder that still holds
// anything else (a hand-written index.mdx, another topic) stops the climb.
// A folder every page left behind is dead: drop it, then the folders above it
// that emptied too (`(problems)`, its topic, the phase). A folder that still
// holds anything (a hand-written index.mdx, another topic) stops the climb.
for (const start of vacated) {
for (let dir = start; dir !== "."; dir = dirname(dir)) {
if (groups.has(dir) || phases.has(dir)) break;
if (folders.has(dir)) break;
let left: string[];
try {
left = await readdir(join(DOCS, dir));
} catch {
break; // already removed via a sibling's climb
}
if (left.some((f) => f !== "meta.ts")) break;
if (left.length) await unlink(join(DOCS, dir, "meta.ts"));
if (left.length) break;
await rmdir(join(DOCS, dir));
report.push(["", dir, "removed (empty)"]);
}
+3 -3
View File
@@ -3,8 +3,8 @@
* Find the NeetCode explanation video for every docs page that has none.
*
* A finished solution page carries frontmatter `video: <YouTube id>` —
* NeetCode's walkthrough of the problem — which the docs MDX `wrapper`
* (`apps/docs/components/ProblemBadges.astro`) renders as the red
* NeetCode's walkthrough of the problem — which the site's MDX `wrapper`
* (prdlk/cs `components/ProblemBadges.astro`) renders as the red
* "Explanation" badge beside the topic badges. `sync.ts` never writes that key
* (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without
* one. This script lists every numbered page lacking `video:` and looks each
@@ -25,7 +25,7 @@ import { join } from "node:path";
import { printTable } from "./report.ts";
import { ROOT } from "./work.ts";
const DOCS = join(ROOT, "apps", "docs", "content");
const DOCS = join(ROOT, "docs");
const WRITE = process.argv.includes("--write");
/** Channel handles searched in order; the first exact match wins. */
-1
View File
@@ -1 +0,0 @@
.blume-verify/
-145
View File
@@ -1,145 +0,0 @@
import { defineConfig } from "blume";
import type { StandardSchema } from "blume/core/standard-schema.ts";
import { filesystem } from "blume/sources";
import redirects from "./redirects";
// Blume's defaults (`inter`, `ibm-plex-mono`) are fetched from fonts.gstatic.com
// on every build, which has timed out on the Pages runner and, fully offline,
// silently ships no fonts at all. The same two families from Fontsource, as
// local files, keep the build network-free. Blume resolves `src` from this
// directory; the workspace links these packages under ./node_modules.
const inter = {
name: "Inter",
variants: [
{
src: "./node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2",
weight: "100..900",
},
],
};
const plexMono = {
name: "IBM Plex Mono",
variants: [
{
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2",
weight: 400,
},
{
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
weight: 500,
},
],
};
/**
* Custom frontmatter, rendered as the badge row by
* components/ProblemBadges.astro. Problem pages: `topics:` is LeetCode's topic
* tags and `issue:` the problem's GitHub issue URL (both written by
* apps/cli/sync.ts), `video:` the NeetCode walkthrough's
* YouTube id (apps/cli/videos.ts). Guide pages (content/appendix/, created by
* sync): `list:` is the tag's LeetCode problem list. Blume rejects undeclared
* keys and takes any Standard Schema; these are hand-rolled so the docs
* workspace needs no zod. All optional — every other page carries none.
*/
const optional = <T>(
message: string,
valid: (value: unknown) => value is T,
): StandardSchema<unknown, T | undefined> => ({
"~standard": {
version: 1,
vendor: "prdlk",
validate: (value) =>
value === undefined || valid(value)
? { value }
: { issues: [{ message }] },
},
});
const topicsSchema = optional(
"expected a list of { name, slug } LeetCode topic tags",
(value): value is { name: string; slug: string }[] =>
Array.isArray(value) &&
value.every(
(t) => typeof t?.name === "string" && typeof t?.slug === "string",
),
);
const videoSchema = optional(
"expected an 11-character YouTube video id",
(value): value is string =>
typeof value === "string" && /^[\w-]{11}$/.test(value),
);
const listSchema = optional(
"expected a https://leetcode.com/problem-list/<slug>/ URL",
(value): value is string =>
typeof value === "string" &&
/^https:\/\/leetcode\.com\/problem-list\/[\w-]+\/$/.test(value),
);
const issueSchema = optional(
"expected a https://github.com/<owner>/<repo>/issues/<n> URL",
(value): value is string =>
typeof value === "string" &&
/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+$/.test(value),
);
export default defineConfig({
title: "prdlk",
description: "Documentation for my LeetCode solutions",
logo: "./public/logo.svg",
deployment: {
site: "https://lc.prd.lk",
},
github: {
owner: "prdlk",
repo: "leetcode",
},
lastModified: "git",
redirects,
frontmatter: {
extend: {
topics: topicsSchema,
video: videoSchema,
list: listSchema,
issue: issueSchema,
},
},
seo: {
og: { enabled: true },
// No `rss`: the changelog is one custom page (pages/changelog.astro), not
// `type: changelog` entries, so a changelog feed would have nothing in it.
sitemap: true,
robots: true,
structuredData: true,
x: { handle: "@prdlk" },
},
// Blume's own assistant route is server-rendered (it reads the provider key
// per request), which a static Pages build cannot ship. The SRS Worker
// answers instead (apps/api/src/ask.ts: OpenRouter, grounded on this site's
// llms-full.txt, which `agents.llmsTxt` publishes).
ai: {
assistant: {
enabled: true,
endpoint: "https://srs-api.prdlk.workers.dev/ask",
},
},
agents: {
llmsTxt: true,
mcp: { enabled: false },
},
// The only content source. `/changelog` is a custom page (pages/
// changelog.astro) reading the GitHub releases itself — one timeline, no
// per-release pages, so nothing of it enters the sidebar or prev/next.
content: {
sources: [filesystem({ root: "content" })],
},
theme: {
fonts: { display: inter, body: inter, mono: plexMono },
},
navigation: {
repo: "https://github.com/prdlk/leetcode",
sidebar: {
display: "flat", // "flat" | "group" | "page"
},
// A plain header link on the right, not a tab.
actions: [{ href: "/changelog", label: "Changelog" }],
},
});
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "theme.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
-20
View File
@@ -1,20 +0,0 @@
/**
* Project MDX components, available on every page with no import, and layout
* slot overrides. Static `.astro` files ship no framework JS (a custom element
* carries any behaviour); interactive React lives in `islands/` instead.
*/
import { defineComponents } from "blume";
export default defineComponents({
mdx: {
Carousel: "./components/Carousel.astro",
CarouselImage: "./components/CarouselImage.astro",
// MDX's own content wrapper: problem pages' topic + explanation badges, from
// `topics:` / `video:` frontmatter, between the description and the body.
wrapper: "./components/ProblemBadges.astro",
},
layout: {
// Topic landing + guide pages' "what links here": every problem pointing at the page.
PageFooter: "./components/BacklinksList.astro",
},
});
-94
View File
@@ -1,94 +0,0 @@
---
/**
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
* or guide page — every problem page that points at it, as `<num> <name>`
* rows in LeetCode-number order. Pages nothing points at (problem pages, the
* overview, the changelog) render nothing.
*
* A problem points at a page when either
* - one of its topic badges links there — the same `tagTarget()` answer
* `ProblemBadges.astro` renders, so this list and the badges cannot drift
* (a DFS problem is a backlink of the one DFS landing its badge picks); or
* - it lives under the page's route (`/hybrid-structures/lru-cache` under
* `/hybrid-structures`), so a topic no LeetCode tag names still lists its
* own problems.
*
* Problem pages are recognised by their `<num>. <name>` title, the shape
* `apps/cli/sync.ts` writes.
*
* The list is a native `<details>`, open on load: the chevron at the top
* right of the header row collapses it like an accordion, with no JS.
*/
import { getEntry } from "astro:content";
import data from "blume:data";
import { withBase } from "blume/components/islands/base-path.ts";
import Icon from "blume/components/Icon.astro";
import { tagTarget, type TopicTag } from "../topic-landings.ts";
interface Props {
route: string;
}
interface Route {
path: string;
title: string;
collection: string;
entryId: string;
}
const { route } = Astro.props;
const routes: Route[] = data.routes;
const served = new Set(routes.map((r) => r.path));
const problems = routes.flatMap((r) => {
const m = r.title.match(/^(\d+)\.\s+(.+)$/);
return m ? [{ route: r, num: Number(m[1]), name: m[2]! }] : [];
});
// A problem page is never a backlink target: skip the per-problem lookups there.
const backlinks = problems.some((p) => p.route.path === route)
? []
: (
await Promise.all(
problems.map(async (p) => {
if (p.route.path.startsWith(`${route}/`)) return p;
const entry = await getEntry(p.route.collection, p.route.entryId);
const tags: TopicTag[] = entry?.data.topics ?? [];
const page = { route: p.route.path, tags };
return tags.some((tag) => tagTarget(tag, page, served).href === route) ? p : undefined;
}),
)
)
.filter((p) => p !== undefined)
.sort((a, b) => a.num - b.num);
---
{
backlinks.length > 0 && (
<details
class="group not-prose mx-auto mt-12 max-w-content border-border border-t pt-6"
open
>
<summary class="mb-2 flex cursor-pointer list-none items-center justify-between gap-2 text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
<h2 class="flex items-baseline gap-2 font-medium text-xs uppercase tracking-wide" id="backlinks">
Backlinks
<span class="tabular-nums">{backlinks.length}</span>
</h2>
<Icon class="shrink-0 transition-transform group-open:rotate-180" name="chevron-down" size={16} />
</summary>
<ul class="text-sm">
{backlinks.map((p) => (
<li>
<a
class="-mx-2 flex items-baseline gap-3 rounded-md px-2 py-1 text-foreground no-underline transition-colors hover:bg-muted"
href={withBase(p.route.path)}
>
<span class="w-10 shrink-0 text-right font-mono text-muted-foreground text-xs tabular-nums">{p.num}</span>
<span class="truncate">{p.name}</span>
</a>
</li>
))}
</ul>
</details>
)
}
-170
View File
@@ -1,170 +0,0 @@
---
/**
* A horizontal carousel of <CarouselImage> slides, built the way Blume's own
* content components are: static markup, a custom element for the behaviour,
* no framework. The track is a CSS scroll-snap strip (so it also swipes on
* touch and the browser does the animation); prev/next sit outside it and
* the play toggle sits below with a "n / N" counter.
*
* `time` is seconds per slide while playing (default 1.25); `autoplay` starts
* playing on load. Play wraps around, pauses whenever the tab is hidden, and
* never starts on its own for a reader who prefers reduced motion — the
* button still works for them. Manual navigation (buttons, arrow keys, swipe)
* does not stop playback; it only restarts the interval so a step is never
* doubled.
*/
import Icon from "blume/components/Icon.astro";
interface Props {
/** Seconds each slide stays while playing. */
time?: number;
autoplay?: boolean;
}
const { time = 1.25, autoplay = false } = Astro.props;
const buttonClass =
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:border-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40";
---
<blume-carousel
class="not-prose my-6 block"
data-time={String(time)}
data-autoplay={autoplay ? "" : undefined}
role="region"
aria-roledescription="carousel"
>
<div class="flex items-center gap-3">
<button class={buttonClass} data-prev type="button" aria-label="Previous slide">
<Icon class="rtl:-scale-x-100" name="chevron-left" size={18} />
</button>
<div
class="flex min-w-0 flex-1 snap-x snap-mandatory overflow-x-auto scroll-smooth rounded-blume [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
data-track
tabindex="0"
aria-live="polite"
>
<slot />
</div>
<button class={buttonClass} data-next type="button" aria-label="Next slide">
<Icon class="rtl:-scale-x-100" name="chevron-right" size={18} />
</button>
</div>
<div class="mt-3 flex items-center justify-center gap-3">
<button class={buttonClass} data-play type="button" aria-label="Play" aria-pressed="false">
<Icon class="[[aria-pressed=true]_&]:hidden" name="play" size={16} />
<Icon class="[[aria-pressed=false]_&]:hidden" name="pause" size={16} />
</button>
<span class="text-muted-foreground text-xs tabular-nums leading-5" data-count></span>
</div>
</blume-carousel>
<script>
class BlumeCarousel extends HTMLElement {
#track!: HTMLElement;
#play!: HTMLButtonElement;
#count!: HTMLElement;
#timer: number | null = null;
#index = 0;
connectedCallback() {
this.#track = this.querySelector("[data-track]")!;
this.#play = this.querySelector("[data-play]")!;
this.#count = this.querySelector("[data-count]")!;
const prev = this.querySelector<HTMLButtonElement>("[data-prev]")!;
const next = this.querySelector<HTMLButtonElement>("[data-next]")!;
const slides = this.#slides().length;
if (slides < 2) {
for (const b of [prev, next, this.#play]) b.disabled = true;
}
this.#render();
prev.addEventListener("click", () => this.#step(-1));
next.addEventListener("click", () => this.#step(1));
this.#play.addEventListener("click", () => (this.#timer === null ? this.#start() : this.#stop()));
this.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft") this.#step(-1);
else if (event.key === "ArrowRight") this.#step(1);
else return;
event.preventDefault();
});
// Swipes and trackpad scrolls move the track without #step; follow them
// once the scroll settles. Settle-only, not per event: a long smooth
// scroll from #step passes over intermediate slides and must not be
// read back as the reader's position mid-flight.
let settle: number | undefined;
this.#track.addEventListener(
"scroll",
() => {
window.clearTimeout(settle);
settle = window.setTimeout(() => this.#syncFromScroll(), 150);
},
{ passive: true }
);
document.addEventListener("visibilitychange", this.#onVisibility);
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (this.hasAttribute("data-autoplay") && slides > 1 && !reduced) this.#start();
}
disconnectedCallback() {
this.#stop();
document.removeEventListener("visibilitychange", this.#onVisibility);
}
#slides(): HTMLElement[] {
return [...this.#track.querySelectorAll<HTMLElement>(":scope > blume-carousel-image")];
}
#step(delta: number) {
const slides = this.#slides();
this.#index = (this.#index + delta + slides.length) % slides.length;
slides[this.#index]!.scrollIntoView({ block: "nearest", inline: "start", behavior: "smooth" });
this.#render();
if (this.#timer !== null) this.#start(); // restart the interval from this slide
}
#syncFromScroll() {
const at = Math.round(this.#track.scrollLeft / this.#track.clientWidth);
if (at !== this.#index && Number.isFinite(at)) {
this.#index = Math.min(Math.max(at, 0), this.#slides().length - 1);
this.#render();
}
}
#start() {
this.#stop();
const ms = Number(this.dataset.time) * 1000;
this.#timer = window.setInterval(() => this.#step(1), ms > 0 ? ms : 1250);
this.#play.setAttribute("aria-pressed", "true");
this.#play.setAttribute("aria-label", "Pause");
}
#stop() {
if (this.#timer !== null) window.clearInterval(this.#timer);
this.#timer = null;
this.#play.setAttribute("aria-pressed", "false");
this.#play.setAttribute("aria-label", "Play");
}
// A background tab keeps its timers but not its layout; resume on return.
#wasPlaying = false;
#onVisibility = () => {
if (document.hidden) {
this.#wasPlaying = this.#timer !== null;
this.#stop();
} else if (this.#wasPlaying) this.#start();
};
#render() {
const slides = this.#slides();
this.#count.textContent = `${this.#index + 1} / ${slides.length}`;
slides.forEach((s, i) => s.setAttribute("aria-hidden", i === this.#index ? "false" : "true"));
}
}
if (!customElements.get("blume-carousel")) {
customElements.define("blume-carousel", BlumeCarousel);
}
</script>
-45
View File
@@ -1,45 +0,0 @@
---
/**
* One slide of a <Carousel>: Blume's own <Frame> around an <img>, so a slide
* is pixel-identical to a framed image elsewhere on the site — `alt` doubles
* as the frame's hint line and `caption` is Markdown, exactly as in Frame.
*
* `src` accepts what MDX authors already write for images: a URL, a
* root-relative `/file.png` (served from public/, prefixed with the deploy
* base), or the page-relative `../../public/file.png` form. A relative path
* that does not pass through public/ has nothing to serve it, so it is a
* build error rather than a silent 404.
*/
import Frame from "blume/components/content/Frame.astro";
import { withBase } from "blume/components/islands/base-path.ts";
interface Props {
src: string;
alt?: string;
caption?: string;
}
const { src, alt, caption } = Astro.props;
const resolveSrc = (value: string): string => {
if (URL.canParse(value)) return value;
if (value.startsWith("/")) return withBase(value);
const at = value.indexOf("public/");
if (at === -1) {
throw new Error(
`<CarouselImage src="${value}">: a relative src must point into public/ (or use /file.png)`
);
}
return withBase(`/${value.slice(at + "public/".length)}`);
};
---
<blume-carousel-image
class="block w-full shrink-0 snap-center [&_blume-frame]:my-0"
role="group"
aria-roledescription="slide"
>
<Frame caption={caption} hint={alt}>
<img alt={alt ?? ""} src={resolveSrc(src)} loading="lazy" draggable="false" />
</Frame>
</blume-carousel-image>
-146
View File
@@ -1,146 +0,0 @@
---
/**
* The MDX `wrapper`: the badge row between a page's description and its body.
* Blume's page template renders the title and description itself and then
* the MDX content, with no slot in between, so wrapping the content is the
* only way to land right after the description. MDX hands a wrapper no
* route, so it is read off the URL, and the page's frontmatter is then
* looked up through the route manifest — the same `getEntry` call Blume's own
* page template makes. Pages with none of the keys below (landing pages, the
* changelog) render only their content.
*
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
* topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that
* topic's landing page and is filled; any other tag links to its guide
* page in the Appendix, `/appendix/<slug>` (sync creates one per such tag), and is outlined.
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
* `PageFooter` backlinks list reads the same answer. Each tag badge is a
* pill carrying the icon its target shows in the sidebar (the topic's
* `meta.ts` icon, the guide's `icon:`) and the tag's own colour
* (`lib/tag-colors.ts`), assigned over every tag the site has a page for —
* no two tags share one, and tags that share problem pages are spread
* furthest apart. A tag with no page (the LeetCode-list fallback) stays
* gray and iconless.
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
* walkthrough's YouTube id, the red "Explanation" badge.
* - `issue:` (problem pages, written by sync) is the problem's GitHub issue
* URL, a neutral "Issue #N" badge with the GitHub mark, right after
* "Explanation" at the end of the row.
* - `list:` (guide pages, written by sync on create) is the tag's LeetCode
* problem list, an orange "LeetCode list" badge.
*/
import { getEntry } from "astro:content";
import data from "blume:data";
import Badge from "blume/components/content/Badge.astro";
import { stripBase, withBase } from "blume/components/islands/base-path.ts";
import { tagColors } from "../lib/tag-colors.ts";
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
// `/sub/tree-dfs/invert-binary-tree/` (or `.../index.html` in a static build)
// -> `/tree-dfs/invert-binary-tree`, the base-less shape of `data.routes`.
const route =
stripBase(import.meta.env.BASE_URL ?? "/", Astro.url.pathname)
.replace(/(\/index)?\.html$/u, "")
.replace(/(.)\/+$/u, "$1");
const at = data.routes.find((r: { path: string }) => r.path === route);
const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
const tags: TopicTag[] = entry?.data.topics ?? [];
const video: string | undefined = entry?.data.video;
const list: string | undefined = entry?.data.list;
const issue: string | undefined = entry?.data.issue;
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
// route → the icon its sidebar row shows: a page's own, or a topic group's
// (from its `meta.ts`) for the landing page the group row links to.
interface NavNode {
route?: string;
icon?: string;
children?: NavNode[];
}
const icons = new Map<string, string>();
const walk = (nodes: NavNode[]) => {
for (const node of nodes) {
if (node.route && node.icon && !icons.has(node.route)) icons.set(node.route, node.icon);
if (node.children) walk(node.children);
}
};
walk(data.navigation.sidebar);
// Every tag with a page — the ones a landing teaches, plus every served guide —
// coloured against how problem pages (`<num>. <name>` titles, the shape sync
// writes) combine them. Only a page with tags needs the answer.
const colors = tags.length
? tagColors(
[
...Object.keys(TOPIC_LANDINGS),
...[...served].flatMap((path) => path.match(/^\/appendix\/([^/]+)$/u)?.slice(1) ?? []),
],
await Promise.all(
data.routes
.filter((r: { title: string }) => /^\d+\.\s/u.test(r.title))
.map(async (r: { collection: string; entryId: string }) => {
const page = await getEntry(r.collection, r.entryId);
return (page?.data.topics ?? []).map((t: TopicTag) => t.slug);
}),
),
)
: new Map<string, string>();
const badges = tags.map((tag) => {
const target = tagTarget(tag, { route, tags }, served);
const external = target.kind === "external";
return {
name: tag.name,
href: external ? target.href : withBase(target.href),
external,
icon: external ? undefined : icons.get(target.href),
color: external ? undefined : colors.get(tag.slug),
stroke: target.kind !== "landing",
};
});
const linkClass = "no-underline transition-opacity hover:opacity-80";
---
{
(badges.length > 0 || video || list || issue) && (
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
{badges.map((b) => (
<a
href={b.href}
target={b.external ? "_blank" : undefined}
rel={b.external ? "noopener" : undefined}
class={linkClass}
>
<Badge color={b.color} icon={b.icon} shape="pill" stroke={b.stroke}>
{b.name}
</Badge>
</a>
))}
{video && (
<a href={`https://www.youtube.com/watch?v=${video}`} target="_blank" rel="noopener" class={linkClass}>
<Badge variant="danger" icon="youtube" shape="pill">
Explanation
</Badge>
</a>
)}
{issue && (
<a href={issue} target="_blank" rel="noopener" class={linkClass}>
<Badge icon="github" shape="pill">
Issue #{issue.slice(issue.lastIndexOf("/") + 1)}
</Badge>
</a>
)}
{list && (
<a href={list} target="_blank" rel="noopener" class={linkClass}>
<Badge variant="warning" icon="list" shape="pill">
LeetCode list
</Badge>
</a>
)}
</div>
)
}
<slot />
-67
View File
@@ -1,67 +0,0 @@
/**
* What the four progress islands share besides the data: the not-ready body
* (a skeleton while /api/stats loads, one muted line if it fails) and the
* hover tooltip for the two charts drawn as plain SVG (the heatmap calendar
* and the topic bubbles — Recharts has neither layout). The tooltip wears the
* exact classes of shadcn's ChartTooltipContent, so all four cards' tooltips
* look like one component.
*/
import { type PointerEvent, type ReactNode, useRef, useState } from "react";
import { cn } from "../../lib/utils";
import type { Load } from "../../lib/stats";
import { Skeleton } from "../ui/skeleton";
export function StatsPending({ load, className }: { load: Exclude<Load, { phase: "ready" }>; className: string }) {
if (load.phase === "loading") return <Skeleton className={className} />;
return (
<p className="my-0! text-muted-foreground text-sm">
Stats unavailable — they are served live from the SRS Worker, and it did not respond.
</p>
);
}
interface Tip {
x: number;
y: number;
content: ReactNode;
}
/**
* A pointer-following tooltip for SVG marks. Put `ref` on a `relative`
* wrapper, call `show` from a mark's pointer events, render `tip` inside the
* wrapper. Positions are relative to the wrapper, so page scroll never skews it.
*/
export function useHoverTip<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [tip, setTip] = useState<Tip | null>(null);
const show = (event: PointerEvent, content: ReactNode) => {
const box = ref.current?.getBoundingClientRect();
if (box) setTip({ x: event.clientX - box.left, y: event.clientY - box.top, content });
};
const hide = () => setTip(null);
const node = tip && (
<div
className={cn(
"pointer-events-none absolute z-10 grid min-w-[8rem] -translate-x-1/2 -translate-y-[calc(100%+0.75rem)] items-start gap-1.5",
"rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
)}
style={{ left: tip.x, top: tip.y }}
>
{tip.content}
</div>
);
return { ref, show, hide, node };
}
/** One tooltip row, the shape ChartTooltipContent draws: colour dot, label, mono value. */
export function TipRow({ color, label, value }: { color: string; label: ReactNode; value: ReactNode }) {
return (
<div className="flex w-full items-center gap-2">
<div className="size-2.5 shrink-0 rounded-[2px]" style={{ background: color }} />
<div className="flex flex-1 items-center justify-between gap-4 leading-none">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium font-mono text-foreground tabular-nums">{value}</span>
</div>
</div>
);
}
-91
View File
@@ -1,91 +0,0 @@
import * as React from "react"
import { cn } from "../../lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
-372
View File
@@ -1,372 +0,0 @@
import * as React from "react"
import { cn } from "../../lib/utils"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
// Format: { THEME_NAME: CSS_SELECTOR }. Blume switches dark mode with
// [data-theme="dark"] on <html>, not shadcn's .dark class.
const THEMES = { light: "", dark: ':root[data-theme="dark"]' } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
-13
View File
@@ -1,13 +0,0 @@
import { cn } from "../../lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
-83
View File
@@ -1,83 +0,0 @@
"use client"
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { toggleVariants } from "./toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
-47
View File
@@ -1,47 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
import { Toggle as TogglePrimitive } from "radix-ui"
// Blume's `accent` is its brand colour, not shadcn's subtle surface: the on
// and hover states use background/muted instead (see theme.css).
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-muted hover:text-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Binary Search", order: 5, icon: "search", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Hash Table", order: 1, icon: "hash", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "1. Linear Structures", order: 1 });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Monotonic Stack", order: 7, icon: "trending-up", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Prefix Sum", order: 2, icon: "sigma", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Sliding Window", order: 4, icon: "app-window", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Stack", order: 6, icon: "layers", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Two Pointers", order: 3, icon: "arrow-left-right", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Hybrid Structures", order: 9, icon: "combine", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Linked List", order: 8, icon: "link", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Matrix Index Math", order: 10, icon: "grid-3x3", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "2. Nodal & Grid", order: 2 });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Binary Search Tree", order: 13, icon: "git-fork", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Heap / Priority Queue", order: 14, icon: "triangle", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "3. Hierarchical", order: 3 });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Tree BFS", order: 12, icon: "network", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Tree DFS", order: 11, icon: "folder-tree", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Trie", order: 15, icon: "spell-check", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Graph BFS", order: 17, icon: "radar", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Graph DFS", order: 16, icon: "waypoints", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "4. Relational", order: 4 });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Topological Sort", order: 18, icon: "workflow", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Union-Find", order: 19, icon: "merge", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "1-D Dynamic Programming", order: 21, icon: "columns-3", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Backtracking", order: 20, icon: "undo-2", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Greedy", order: 23, icon: "coins", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Intervals", order: 24, icon: "calendar-range", display: "group" });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "5. Decision Space", order: 5 });
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Multi-D / Grid DP", order: 22, icon: "table", display: "group" });
@@ -1,11 +0,0 @@
---
title: "Array"
list: "https://leetcode.com/problem-list/array/"
icon: list-ordered
sidebar:
badge: '45'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Array.
:::
@@ -1,11 +0,0 @@
---
title: "Binary Tree"
list: "https://leetcode.com/problem-list/binary-tree/"
icon: git-branch
sidebar:
badge: '9'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Binary Tree.
:::
@@ -1,11 +0,0 @@
---
title: "Directed Acyclic Graph"
list: "https://leetcode.com/problem-list/directed-acyclic-graph/"
icon: route
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Directed Acyclic Graph.
:::
@@ -1,11 +0,0 @@
---
title: "Doubly-Linked List"
list: "https://leetcode.com/problem-list/doubly-linked-list/"
icon: link-2
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Doubly-Linked List.
:::
@@ -1,11 +0,0 @@
---
title: "Graph Theory"
list: "https://leetcode.com/problem-list/graph/"
icon: share-2
sidebar:
badge: '5'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Graph Theory.
:::
@@ -1,11 +0,0 @@
---
title: "K-D Tree"
list: "https://leetcode.com/problem-list/k-d-tree/"
icon: axis-3d
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged K-D Tree.
:::
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Data Structures", order: 1 });
@@ -1,11 +0,0 @@
---
title: "Queue"
list: "https://leetcode.com/problem-list/queue/"
icon: list-end
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Queue.
:::
@@ -1,11 +0,0 @@
---
title: "String"
list: "https://leetcode.com/problem-list/string/"
icon: quote
sidebar:
badge: '17'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged String.
:::
@@ -1,11 +0,0 @@
---
title: "Tree"
list: "https://leetcode.com/problem-list/tree/"
icon: tree-deciduous
sidebar:
badge: '9'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Tree.
:::
@@ -1,11 +0,0 @@
---
title: "Bidirectional Search"
list: "https://leetcode.com/problem-list/bidirectional-search/"
icon: move-horizontal
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Bidirectional Search.
:::
@@ -1,11 +0,0 @@
---
title: "Binary Lifting"
list: "https://leetcode.com/problem-list/binary-lifting/"
icon: chevrons-up
sidebar:
badge: '2'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Binary Lifting.
:::
@@ -1,11 +0,0 @@
---
title: "Bit Manipulation"
list: "https://leetcode.com/problem-list/bit-manipulation/"
icon: binary
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Bit Manipulation.
:::
@@ -1,11 +0,0 @@
---
title: "Bracket Sequences"
list: "https://leetcode.com/problem-list/bracket-sequences/"
icon: parentheses
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Bracket Sequences.
:::
@@ -1,11 +0,0 @@
---
title: "Bucket Sort"
list: "https://leetcode.com/problem-list/bucket-sort/"
icon: container
sidebar:
badge: '2'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Bucket Sort.
:::
@@ -1,11 +0,0 @@
---
title: "Complete Knapsack"
list: "https://leetcode.com/problem-list/complete-knapsack/"
icon: package-plus
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Complete Knapsack.
:::
@@ -1,11 +0,0 @@
---
title: "Divide and Conquer"
list: "https://leetcode.com/problem-list/divide-and-conquer/"
icon: split
sidebar:
badge: '5'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Divide and Conquer.
:::
@@ -1,11 +0,0 @@
---
title: "Floyd's Cycle Finding Algorithm"
list: "https://leetcode.com/problem-list/floyds-cycle-finding-algorithm/"
icon: repeat
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Floyd's Cycle Finding Algorithm.
:::
@@ -1,11 +0,0 @@
---
title: "Knapsack Problem"
list: "https://leetcode.com/problem-list/knapsack-problem/"
icon: backpack
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Knapsack Problem.
:::
@@ -1,11 +0,0 @@
---
title: "Longest Common Subsequence"
list: "https://leetcode.com/problem-list/longest-common-subsequence/"
icon: diff
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Longest Common Subsequence.
:::
@@ -1,11 +0,0 @@
---
title: "Lowest Common Ancestor"
list: "https://leetcode.com/problem-list/lowest-common-ancestor/"
icon: git-merge
sidebar:
badge: '2'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Lowest Common Ancestor.
:::
@@ -1,11 +0,0 @@
---
title: "Memoization"
list: "https://leetcode.com/problem-list/memoization/"
icon: database
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Memoization.
:::
@@ -1,11 +0,0 @@
---
title: "Merge Sort"
list: "https://leetcode.com/problem-list/merge-sort/"
icon: blend
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Merge Sort.
:::
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Algorithms", order: 2 });
@@ -1,11 +0,0 @@
---
title: "Quickselect"
list: "https://leetcode.com/problem-list/quickselect/"
icon: crosshair
sidebar:
badge: '3'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Quickselect.
:::
@@ -1,11 +0,0 @@
---
title: "Quicksort"
icon: shuffle
list: "https://leetcode.com/problem-list/quicksort/"
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Quicksort.
:::
@@ -1,11 +0,0 @@
---
title: "Recursion"
list: "https://leetcode.com/problem-list/recursion/"
icon: iteration-ccw
sidebar:
badge: '3'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Recursion.
:::
@@ -1,11 +0,0 @@
---
title: "Simulation"
list: "https://leetcode.com/problem-list/simulation/"
icon: monitor-play
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Simulation.
:::
@@ -1,11 +0,0 @@
---
title: "Sorting"
list: "https://leetcode.com/problem-list/sorting/"
icon: arrow-down-wide-narrow
sidebar:
badge: '12'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Sorting.
:::
@@ -1,11 +0,0 @@
---
title: "Tournament Sort"
list: "https://leetcode.com/problem-list/tournament-sort/"
icon: trophy
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Tournament Sort.
:::
@@ -1,11 +0,0 @@
---
title: "Combinatorics"
list: "https://leetcode.com/problem-list/combinatorics/"
icon: dices
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Combinatorics.
:::
@@ -1,11 +0,0 @@
---
title: "Counting"
list: "https://leetcode.com/problem-list/counting/"
icon: tally-5
sidebar:
badge: '3'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Counting.
:::
@@ -1,11 +0,0 @@
---
title: "Design"
list: "https://leetcode.com/problem-list/design/"
icon: pencil-ruler
sidebar:
badge: '5'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Design.
:::
@@ -1,11 +0,0 @@
---
title: "Geometry"
list: "https://leetcode.com/problem-list/geometry/"
icon: shapes
sidebar:
badge: '1'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Geometry.
:::
@@ -1,11 +0,0 @@
---
title: "Math"
list: "https://leetcode.com/problem-list/math/"
icon: calculator
sidebar:
badge: '6'
---
:::note
This guide has not been written yet. The LeetCode list above has every problem tagged Math.
:::
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Techniques", order: 3 });
-3
View File
@@ -1,3 +0,0 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Appendix", icon: "library", order: 0, display: "page" });
-377
View File
@@ -1,377 +0,0 @@
/**
* Dependency spine island — the "how the 24 topics build on each other" map
* from the README, redrawn as data (nodes on a 7×6 grid, edges as SVG path
* strings) so it is themed, zoomable, and clickable.
*
* - Wheel over the figure zooms about the cursor (trackpad pinch arrives as a
* ctrl+wheel and behaves the same); dragging pans; the toolbar and +/-/0
* keys do the same. Zooming OUT at the minimum scale is deliberately left
* to the page, so a reader scrolling past the map is never trapped in it.
* - A node is a link to its topic. Routes come from Blume's own navigation
* endpoint at mount: the topic's landing page when the folder has an
* `index.mdx`, else its first solution page. A topic with no pages yet is
* drawn inert rather than linked to a 404, so the map stays honest as the
* campaign fills in without a rebuild of this file.
*
* Invariants: the first frame is the figure at HOME_ZOOM, centred, with no
* links (server and client render identically); the geometry is the README
* SVG's, to the pixel, so `viewBox` maths below is in that 1472×648 space.
*/
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { withBase } from "blume/components/islands/base-path.ts";
export const client = "visible";
// ── figure data (README dependency-spine geometry) ───────────────
const W = 1472;
const H = 648;
const MAX_ZOOM = 6;
const NODE_W = 144;
const NODE_H = 48;
const COL_X = [56, 248, 472, 664, 888, 1080, 1272] as const;
const ROW_Y = [72, 144, 216, 288, 416, 488] as const;
type Kind = "entry" | "topic" | "focal";
interface Topic {
n: number;
name: string;
sub?: string;
/** Sync's group slug for the README topic name — the docs folder. */
slug: string;
kind: Kind;
col: number;
row: number;
}
const TOPICS: Topic[] = [
{ n: 5, name: "Binary Search", slug: "binary-search", kind: "entry", col: 0, row: 0 },
{ n: 3, name: "Two Pointers", slug: "two-pointers", kind: "entry", col: 0, row: 1 },
{ n: 4, name: "Sliding Window", slug: "sliding-window", kind: "topic", col: 1, row: 1 },
{ n: 6, name: "Stack", slug: "stack", kind: "entry", col: 0, row: 2 },
{ n: 7, name: "Monotonic Stack", slug: "monotonic-stack", kind: "topic", col: 1, row: 2 },
{ n: 1, name: "Hash Lookup", slug: "hash-table", kind: "entry", col: 0, row: 3 },
{ n: 2, name: "Prefix Sum", slug: "prefix-sum", kind: "topic", col: 1, row: 3 },
{ n: 11, name: "Tree DFS", slug: "tree-dfs", kind: "focal", col: 2, row: 0 },
{ n: 13, name: "Binary Search Tree", slug: "binary-search-tree", kind: "topic", col: 2, row: 1 },
{ n: 14, name: "Heap", sub: "priority queue", slug: "heap-priority-queue", kind: "topic", col: 3, row: 1 },
{ n: 15, name: "Trie", slug: "trie", kind: "topic", col: 3, row: 2 },
{ n: 12, name: "Tree BFS", slug: "tree-bfs", kind: "entry", col: 2, row: 3 },
{ n: 8, name: "Linked List", slug: "linked-list", kind: "entry", col: 2, row: 4 },
{ n: 9, name: "Hybrid Structures", slug: "hybrid-structures", kind: "topic", col: 3, row: 4 },
{ n: 10, name: "Matrix Index Math", slug: "matrix-index-math", kind: "topic", col: 2, row: 5 },
{ n: 16, name: "Graph DFS", slug: "graph-dfs", kind: "topic", col: 4, row: 0 },
{ n: 18, name: "Topological Sort", slug: "topological-sort", kind: "topic", col: 5, row: 0 },
{ n: 19, name: "Union-Find", slug: "union-find", kind: "topic", col: 4, row: 1 },
{ n: 20, name: "Backtracking", slug: "backtracking", kind: "topic", col: 5, row: 1 },
{ n: 21, name: "1-D DP", sub: "dynamic programming", slug: "1-d-dynamic-programming", kind: "focal", col: 5, row: 2 },
{ n: 22, name: "Multi-D / Grid DP", slug: "multi-d-grid-dp", kind: "topic", col: 6, row: 2 },
{ n: 17, name: "Graph BFS", slug: "graph-bfs", kind: "topic", col: 4, row: 3 },
{ n: 23, name: "Greedy", slug: "greedy", kind: "topic", col: 5, row: 3 },
{ n: 24, name: "Intervals", slug: "intervals", kind: "topic", col: 6, row: 3 },
];
/** "builds on" arrows, source → target, in the figure's coordinate space. */
const EDGES: [string, string][] = [
["3 → 4", "M200,168 H248"],
["6 → 7", "M200,240 H248"],
["1 → 2", "M200,312 H248"],
["8 → 9", "M616,440 H664"],
["16 → 18", "M1032,96 H1080"],
["21 → 22", "M1224,240 H1272"],
["23 → 24", "M1224,312 H1272"],
["11 → 16", "M616,84 H888"],
["12 → 17", "M616,312 H888"],
["11 → 13", "M544,120 V144"],
["16 → 19", "M960,120 V144"],
["18 → 20", "M1152,120 V144"],
["20 → 21", "M1152,192 V216"],
["21 → 23", "M1152,264 V288"],
["5 → 13", "M200,96 H416 Q424,96 424,104 V160 Q424,168 432,168 H472"],
["11 → 14", "M616,96 H640 Q648,96 648,104 V160 Q648,168 656,168 H664"],
["11 → 15", "M616,108 H624 Q632,108 632,116 V232 Q632,240 640,240 H664"],
["4 → 10", "M392,184 H432 Q440,184 440,192 V504 Q440,512 448,512 H472"],
["1 → 9", "M128,336 V560 Q128,568 136,568 H728 Q736,568 736,560 V464"],
];
const ZONES: [string, number, number, number, number, number][] = [
// label, x, y, w, h, label plate width
["PHASE I — LINEAR STRUCTURES", 40, 40, 368, 312, 168],
["PHASE III — HIERARCHICAL", 456, 40, 368, 312, 152],
["PHASE II — NODAL & GRID", 456, 384, 368, 168, 144],
["PHASES IV–V — RELATIONAL & DECISION-SPACE", 872, 40, 560, 312, 264],
];
// ── theme ────────────────────────────────────────────────────────
// Everything is a Blume token so the map follows the site's light/dark
// scheme instead of the README's fixed black; tints go through color-mix.
const fg = "var(--blume-foreground)";
const muted = "var(--blume-muted-foreground)";
const accent = "var(--blume-accent)";
const tint = (color: string, pct: number) => `color-mix(in oklab, ${color} ${pct}%, transparent)`;
const NODE_STYLE: Record<Kind, { fill: string; stroke: string; strokeWidth: number; tag: string }> = {
entry: { fill: tint(muted, 10), stroke: tint(muted, 70), strokeWidth: 1, tag: muted },
topic: { fill: tint(fg, 4), stroke: fg, strokeWidth: 1, tag: muted },
focal: { fill: tint(accent, 12), stroke: accent, strokeWidth: 1.2, tag: accent },
};
const mono: CSSProperties = { fontFamily: "var(--blume-font-mono, ui-monospace, monospace)" };
const sans: CSSProperties = { fontFamily: "var(--blume-font-sans, system-ui, sans-serif)" };
const toolButton: CSSProperties = {
width: "1.75rem",
height: "1.75rem",
border: "1px solid var(--blume-border)",
borderRadius: "9999px",
background: "var(--blume-background)",
color: muted,
font: "inherit",
lineHeight: 1,
cursor: "pointer",
};
// ── navigation lookup ────────────────────────────────────────────
interface NavGroup {
kind: "group";
path: string;
route?: string | null;
children: unknown[];
}
const isGroup = (v: unknown): v is NavGroup =>
typeof v === "object" && v !== null && (v as NavGroup).kind === "group" &&
typeof (v as NavGroup).path === "string" && Array.isArray((v as NavGroup).children);
/** The first page route under a sidebar entry, depth-first. */
const firstRoute = (items: unknown[]): string | null => {
for (const item of items) {
if (typeof item !== "object" || item === null) continue;
const v = item as { route?: unknown; children?: unknown };
if (typeof v.route === "string") return v.route;
if (Array.isArray(v.children)) {
const inner = firstRoute(v.children);
if (inner) return inner;
}
}
return null;
};
/** folder path (`/tree-dfs`) → the route a node should open. */
const topicRoutes = (nav: unknown): Record<string, string> => {
const out: Record<string, string> = {};
const sidebar = (nav as { sidebar?: unknown })?.sidebar;
if (!Array.isArray(sidebar)) return out;
for (const entry of sidebar) {
if (!isGroup(entry)) continue;
const route = entry.route ?? firstRoute(entry.children);
if (route) out[entry.path] = route;
}
return out;
};
// ── component ────────────────────────────────────────────────────
interface ViewBox { x: number; y: number; w: number; h: number }
/** The resting view — 1.6× on the centre so node labels read at the content width; 1× stays reachable by zooming out. */
const HOME_ZOOM = 1.6;
const HOME: ViewBox = { x: (W - W / HOME_ZOOM) / 2, y: (H - H / HOME_ZOOM) / 2, w: W / HOME_ZOOM, h: H / HOME_ZOOM };
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
export default function DependencySpine() {
const svgRef = useRef<SVGSVGElement>(null);
const [vb, setVbState] = useState<ViewBox>(HOME);
// The native wheel listener and back-to-back events read the latest view
// from the ref, which is written synchronously — state alone lags a render.
const vbRef = useRef(vb);
const setVb = (next: ViewBox) => {
vbRef.current = next;
setVbState(next);
};
const [routes, setRoutes] = useState<Record<string, string> | null>(null);
const drag = useRef<{ x: number; y: number; moved: boolean } | null>(null);
useEffect(() => {
let live = true;
fetch(withBase("/api/docs/navigation.json"))
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((nav) => live && setRoutes(topicRoutes(nav)))
.catch(() => live && setRoutes({}));
return () => { live = false; };
}, []);
/** Zoom by `factor` (>1 in) about a client-space point; omitted = centre. */
const zoomAt = (factor: number, clientX?: number, clientY?: number) => {
const rect = svgRef.current?.getBoundingClientRect();
const cur = vbRef.current;
if (!rect) return;
const px = clientX === undefined ? cur.x + cur.w / 2 : cur.x + ((clientX - rect.left) / rect.width) * cur.w;
const py = clientY === undefined ? cur.y + cur.h / 2 : cur.y + ((clientY - rect.top) / rect.height) * cur.h;
const w = clamp(cur.w / factor, W / MAX_ZOOM, W);
const f = cur.w / w;
const h = H * (w / W);
setVb({ x: clamp(px - (px - cur.x) / f, 0, W - w), y: clamp(py - (py - cur.y) / f, 0, H - h), w, h });
};
// React registers `wheel` as passive, so preventDefault has to go native.
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY > 0 && vbRef.current.w >= W) return; // fully zoomed out: let the page scroll
e.preventDefault();
zoomAt(Math.exp(-e.deltaY * 0.0015), e.clientX, e.clientY);
};
svg.addEventListener("wheel", onWheel, { passive: false });
return () => svg.removeEventListener("wheel", onWheel);
}, []);
const onPointerDown = (e: PointerEvent<SVGSVGElement>) => {
if (e.button !== 0) return;
drag.current = { x: e.clientX, y: e.clientY, moved: false };
};
const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {
const d = drag.current;
const rect = svgRef.current?.getBoundingClientRect();
if (!d || !rect) return;
const dx = e.clientX - d.x;
const dy = e.clientY - d.y;
if (!d.moved && Math.hypot(dx, dy) < 4) return; // a click, not a drag
if (!d.moved) {
// Capture only once it is a drag: capturing on pointerdown would retarget
// pointerup (and so the click) to the <svg>, and a node's link would never fire.
d.moved = true;
e.currentTarget.setPointerCapture(e.pointerId);
}
d.x = e.clientX;
d.y = e.clientY;
const cur = vbRef.current;
const scale = cur.w / rect.width;
setVb({ ...cur, x: clamp(cur.x - dx * scale, 0, W - cur.w), y: clamp(cur.y - dy * scale, 0, H - cur.h) });
};
const onPointerUp = (e: PointerEvent<SVGSVGElement>) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
// Cleared on the next tick so a node's click handler can still see `moved`.
setTimeout(() => { drag.current = null; }, 0);
};
const zoom = W / vb.w;
const atHome = vb.x === HOME.x && vb.y === HOME.y && vb.w === HOME.w;
return (
<figure
className="not-prose"
style={{ margin: "1.5rem 0", border: "1px solid var(--blume-border)", borderRadius: "var(--blume-radius)", background: tint("var(--blume-muted)", 30), overflow: "hidden" }}
aria-label="Dependency spine: how the 24 topics build on each other"
onKeyDown={(e) => {
if (e.key === "+" || e.key === "=") zoomAt(1.25);
else if (e.key === "-") zoomAt(0.8);
else if (e.key === "0") setVb(HOME);
else return;
e.preventDefault();
}}
tabIndex={0}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.75rem", padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--blume-border)" }}>
<span style={{ ...mono, fontSize: "0.7rem", letterSpacing: "0.12em", textTransform: "uppercase", color: muted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
scroll to zoom · drag to pan · click a topic
</span>
<span style={{ display: "flex", alignItems: "center", gap: "0.35rem", flexShrink: 0 }}>
<span style={{ ...mono, fontSize: "0.7rem", color: muted, minWidth: "4ch", textAlign: "right", whiteSpace: "nowrap" }}>{zoom.toFixed(1)}×</span>
<button type="button" style={toolButton} aria-label="Zoom out" onClick={() => zoomAt(0.8)} disabled={zoom <= 1.001}>−</button>
<button type="button" style={toolButton} aria-label="Zoom in" onClick={() => zoomAt(1.25)} disabled={zoom >= MAX_ZOOM - 0.001}>+</button>
<button type="button" style={toolButton} aria-label="Reset zoom" onClick={() => setVb(HOME)} disabled={atHome}>⟲</button>
</span>
</div>
<svg
ref={svgRef}
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="dependency-spine-title dependency-spine-desc"
style={{ display: "block", width: "100%", height: "auto", touchAction: "none", cursor: drag.current?.moved ? "grabbing" : zoom > 1.001 ? "grab" : "default", userSelect: "none" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onDoubleClick={() => setVb(HOME)}
>
<title id="dependency-spine-title">LeetCode topic dependency spine</title>
<desc id="dependency-spine-desc">
Dependency graph of 24 LeetCode study topics across five phases, showing which topics build on which, with Tree DFS and 1-D dynamic programming as the two central hubs.
</desc>
<defs>
<marker id="spine-arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" style={{ fill: muted }} />
</marker>
</defs>
{ZONES.map(([label, x, y, w, h, plate]) => (
<g key={label}>
<rect x={x} y={y} width={w} height={h} rx={8} style={{ fill: tint(fg, 2), stroke: tint(fg, 10), strokeWidth: 0.8 }} />
<rect x={x + 16} y={y + 4} width={plate} height={12} rx={2} style={{ fill: "var(--blume-background)" }} />
<text x={x + 20} y={y + 13} fontSize={7} letterSpacing="0.14em" style={{ ...mono, fill: tint(fg, 40) }}>{label}</text>
</g>
))}
{EDGES.map(([id, d]) => (
<path key={id} d={d} fill="none" strokeWidth={1} markerEnd="url(#spine-arrow)" style={{ stroke: muted }}>
<title>{id}</title>
</path>
))}
{TOPICS.map((t) => {
const x = COL_X[t.col]!;
const y = ROW_Y[t.row]!;
const s = NODE_STYLE[t.kind];
const route = routes?.[`/${t.slug}`];
const tag = String(t.n).padStart(2, "0");
const body = (
<>
<title>{route ? `${tag} · ${t.name} — open topic` : `${tag} · ${t.name} — no pages yet`}</title>
<rect x={x} y={y} width={NODE_W} height={NODE_H} rx={6} style={{ fill: "var(--blume-background)" }} />
<rect x={x} y={y} width={NODE_W} height={NODE_H} rx={6} style={{ fill: s.fill, stroke: s.stroke, strokeWidth: s.strokeWidth }} />
<rect x={x + 8} y={y + 6} width={24} height={12} rx={2} fill="transparent" style={{ stroke: tint(s.tag, 50), strokeWidth: 0.8 }} />
<text x={x + 20} y={y + 15} fontSize={7} textAnchor="middle" letterSpacing="0.08em" style={{ ...mono, fill: s.tag }}>{tag}</text>
<text x={x + NODE_W / 2} y={y + (t.sub ? 26 : 32)} fontSize={12} fontWeight={600} textAnchor="middle" style={{ ...sans, fill: fg }}>{t.name}</text>
{t.sub && <text x={x + NODE_W / 2} y={y + 40} fontSize={9} textAnchor="middle" style={{ ...mono, fill: muted }}>{t.sub}</text>}
</>
);
return route ? (
<a
key={t.n}
href={withBase(route)}
className="spine-node"
style={{ cursor: "pointer", textDecoration: "none" }}
onClick={(e) => { if (drag.current?.moved) e.preventDefault(); }}
>
{body}
</a>
) : (
<g key={t.n} style={{ opacity: routes ? 0.55 : 1 }} aria-disabled="true">{body}</g>
);
})}
<line x1={40} y1={600} x2={1432} y2={600} strokeWidth={0.8} style={{ stroke: tint(fg, 10) }} />
<text x={40} y={620} fontSize={8} letterSpacing="0.14em" style={{ ...mono, fill: muted }}>LEGEND</text>
<rect x={160} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.entry.fill, stroke: NODE_STYLE.entry.stroke, strokeWidth: 1 }} />
<text x={188} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>ENTRY — NO PREREQS</text>
<rect x={360} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.topic.fill, stroke: NODE_STYLE.topic.stroke, strokeWidth: 1 }} />
<text x={388} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>TOPIC</text>
<rect x={480} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.focal.fill, stroke: NODE_STYLE.focal.stroke, strokeWidth: 1.2 }} />
<text x={508} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>FOCAL HUB</text>
<line x1={624} y1={614} x2={656} y2={614} strokeWidth={1} markerEnd="url(#spine-arrow)" style={{ stroke: muted }} />
<text x={668} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>BUILDS ON</text>
<rect x={792} y={608} width={20} height={12} rx={2} style={{ fill: tint(fg, 2), stroke: tint(fg, 10), strokeWidth: 0.8 }} />
<text x={820} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>PHASE ZONE</text>
<rect x={1000} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.topic.fill, stroke: NODE_STYLE.topic.stroke, strokeWidth: 1, opacity: 0.55 }} />
<text x={1028} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>NO PAGES YET</text>
</svg>
<style>{`.spine-node:hover rect:nth-of-type(2), .spine-node:focus-visible rect:nth-of-type(2) { stroke: ${accent}; stroke-width: 1.6; } .spine-node:focus { outline: none; }`}</style>
</figure>
);
}
-81
View File
@@ -1,81 +0,0 @@
/**
* The ten most recently solved problems — LeetCode's "Recent AC" list. Each
* row is dated by the problem's first pass (an ET calendar date: the Worker
* logs days, not times), read relative to today in ET with the full date on
* hover. A row links to the problem's docs page when one exists: the
* sync-generated short links (`redirects.ts`) are exactly the pages built.
*/
import { StatsPending } from "../components/progress/shared";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { useStats, utcDate } from "../lib/stats";
import redirects from "../redirects";
export const client = "load";
const PAGES = new Map(redirects.map((r) => [r.from, r.to]));
const ET_TODAY = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" });
const RELATIVE = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });
const DAY_MS = 86_400_000;
export default function RecentSolves() {
const load = useStats();
if (load.phase !== "ready") {
return (
<Card>
<CardHeader>
<CardTitle>Recently solved</CardTitle>
</CardHeader>
<CardContent>
<StatsPending load={load} className="h-80 w-full" />
</CardContent>
</Card>
);
}
const today = utcDate(ET_TODAY.format(new Date())).getTime();
return (
<Card>
<CardHeader>
<CardTitle>Recently solved</CardTitle>
</CardHeader>
<CardContent>
{load.stats.recent.length === 0 ? (
<p className="my-0! text-muted-foreground text-sm">Nothing solved yet.</p>
) : (
<ol className="my-0! flex flex-col">
{load.stats.recent.map((p) => {
const date = utcDate(p.date);
const page = PAGES.get(`/${p.lc}`);
const name = (
<>
<span className="text-muted-foreground tabular-nums">{p.lc}.</span> {p.title}
</>
);
return (
<li
key={p.lc}
className="flex items-center justify-between gap-4 rounded-lg px-4 py-3 text-sm odd:bg-muted"
>
{page ? (
<a href={page} className="truncate font-normal! text-foreground no-underline! hover:underline!">
{name}
</a>
) : (
<span className="truncate">{name}</span>
)}
<time dateTime={p.date} title={FULL.format(date)} className="shrink-0 text-muted-foreground">
{RELATIVE.format(Math.round((date.getTime() - today) / DAY_MS), "day")}
</time>
</li>
);
})}
</ol>
)}
</CardContent>
</Card>
);
}
-145
View File
@@ -1,145 +0,0 @@
/**
* Solved-by-difficulty ring — LeetCode's profile gauge. A 270° arc split into
* one segment per difficulty, each sized by that difficulty's share of the
* catalog; the faint track is the whole set, the solid arc the solved part.
* The centre reads the total, or one difficulty while its segment (or its
* stat box) is hovered. "Attempting" is the open-miss count: problems whose
* latest attempt failed.
*/
import { useState } from "react";
import { Pie, PieChart } from "recharts";
import { StatsPending } from "../components/progress/shared";
import { Card, CardContent } from "../components/ui/card";
import { type ChartConfig, ChartContainer } from "../components/ui/chart";
import { DIFFICULTIES, type Difficulty, useStats } from "../lib/stats";
import { cn } from "../lib/utils";
export const client = "load";
const chartConfig = {
easy: { label: "Easy", color: "var(--chart-1)" },
medium: { label: "Med.", color: "var(--chart-2)" },
hard: { label: "Hard", color: "var(--chart-3)" },
} satisfies ChartConfig;
// Static class names, so Tailwind's scanner sees every one (the chart's
// --color-* variables exist only inside ChartContainer).
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
easy: "text-chart-1",
medium: "text-chart-2",
hard: "text-chart-3",
};
// The gauge opens at the bottom: from 225° (lower left) clockwise to -45°
// (lower right), in Recharts' counter-clockwise-from-3-o'clock degrees.
const START = 225;
const SWEEP = 270;
const GAP = 8;
export default function SolvedRing() {
const load = useStats();
const [focus, setFocus] = useState<Difficulty | null>(null);
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-52 w-full" />
</CardContent>
</Card>
);
}
const rows = DIFFICULTIES.map(
(difficulty) => load.stats.difficulty.find((d) => d.difficulty === difficulty) ?? { difficulty, total: 0, done: 0 },
);
const total = rows.reduce((n, r) => n + r.total, 0);
const done = rows.reduce((n, r) => n + r.done, 0);
const span = SWEEP - GAP * (rows.length - 1);
let cursor = START;
const segments = rows.map((r) => {
const size = total > 0 ? (span * r.total) / total : 0;
const from = cursor;
cursor -= size + GAP;
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
return { ...r, from, to: from - size, solvedTo: from - solved };
});
const focused = focus ? rows.find((r) => r.difficulty === focus) : undefined;
const arc = { innerRadius: "80%", outerRadius: "88%", cornerRadius: 99, stroke: "none", dataKey: "value" } as const;
return (
<Card>
<CardContent className="flex flex-col items-center gap-6 sm:flex-row">
<div className="relative w-full max-w-56 shrink-0">
<ChartContainer config={chartConfig} className="aspect-square w-full">
<PieChart>
{segments.map((s) => (
<Pie
key={`${s.difficulty}-track`}
{...arc}
data={[{ value: 1 }]}
startAngle={s.from}
endAngle={s.to}
fill={`var(--color-${s.difficulty})`}
fillOpacity={focus && focus !== s.difficulty ? 0.12 : 0.25}
isAnimationActive={false}
onMouseEnter={() => setFocus(s.difficulty)}
onMouseLeave={() => setFocus(null)}
/>
))}
{segments
.filter((s) => s.done > 0)
.map((s) => (
<Pie
key={`${s.difficulty}-solved`}
{...arc}
data={[{ value: 1 }]}
startAngle={s.from}
endAngle={s.solvedTo}
fill={`var(--color-${s.difficulty})`}
fillOpacity={focus && focus !== s.difficulty ? 0.35 : 1}
onMouseEnter={() => setFocus(s.difficulty)}
onMouseLeave={() => setFocus(null)}
/>
))}
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1">
<div className="flex items-baseline">
<span className="font-semibold text-4xl tabular-nums">{focused ? focused.done : done}</span>
<span className="text-muted-foreground tabular-nums">/{focused ? focused.total : total}</span>
</div>
<span className={cn("text-sm", focused ? DIFFICULTY_TEXT[focused.difficulty] : "text-muted-foreground")}>
{focused ? chartConfig[focused.difficulty].label : "Solved"}
</span>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-3 text-center text-muted-foreground text-xs">
<span className="font-semibold text-foreground tabular-nums">{load.stats.attempting}</span> Attempting
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-1">
{rows.map((r) => (
<div
key={r.difficulty}
className={cn(
"flex flex-col items-center gap-0.5 rounded-lg bg-muted px-4 py-2 transition-opacity",
focus && focus !== r.difficulty && "opacity-50",
)}
onPointerEnter={() => setFocus(r.difficulty)}
onPointerLeave={() => setFocus(null)}
>
<span className={cn("font-medium text-sm", DIFFICULTY_TEXT[r.difficulty])}>
{chartConfig[r.difficulty].label}
</span>
<span className="text-sm tabular-nums">
{r.done}/{r.total}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
-222
View File
@@ -1,222 +0,0 @@
/**
* This calendar year by difficulty — LeetCode's solved/submissions bar chart,
* sharing its row with the heatmap. Stacked Easy / Med. / Hard bars per month
* or per week (Monday-start, like the heatmap rows); "Solved" counts each
* problem once, on its first-pass date (the date `recent` uses), while
* "Submissions" counts every logged attempt. The totals under the chart are
* the year's, in the selected mode. Buckets are cut from `year`, which always
* covers January 1 to today.
*/
import { useState } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { StatsPending } from "../components/progress/shared";
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "../components/ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "../components/ui/chart";
import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
import { DIFFICULTIES, type Day, type Difficulty, useStats, utcDate } from "../lib/stats";
export const client = "load";
const chartConfig = {
easy: { label: "Easy", color: "var(--chart-1)" },
medium: { label: "Med.", color: "var(--chart-2)" },
hard: { label: "Hard", color: "var(--chart-3)" },
} satisfies ChartConfig;
// Static class names, so Tailwind's scanner sees every one.
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
easy: "text-chart-1",
medium: "text-chart-2",
hard: "text-chart-3",
};
type Mode = "solved" | "attempts";
type Grain = "month" | "week";
const DAY_MS = 86_400_000;
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
const MONTH_YEAR = new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
const SHORT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
interface Bucket extends Record<Difficulty, number> {
index: number;
/** Axis tick: the month the bucket starts in. */
tick: string;
/** Tooltip heading. */
label: string;
}
/** The year's buckets, every one present (future ones empty), filled from `days`. */
function buckets(days: Day[], year: number, grain: Grain, mode: Mode): Bucket[] {
const jan1 = Date.UTC(year, 0, 1);
const lead = (new Date(jan1).getUTCDay() + 6) % 7; // Jan 1's Monday-based weekday
const length = Math.round((Date.UTC(year + 1, 0, 1) - jan1) / DAY_MS);
const out: Bucket[] = [];
if (grain === "month") {
for (let m = 0; m < 12; m++) {
const start = new Date(Date.UTC(year, m, 1));
out.push({ index: m, tick: MONTH.format(start), label: MONTH_YEAR.format(start), easy: 0, medium: 0, hard: 0 });
}
} else {
const weeks = Math.floor((length - 1 + lead) / 7) + 1;
for (let w = 0; w < weeks; w++) {
const start = new Date(Math.max(jan1, jan1 + (w * 7 - lead) * DAY_MS));
const end = new Date(Math.min(jan1 + (length - 1) * DAY_MS, jan1 + (w * 7 - lead + 6) * DAY_MS));
out.push({
index: w,
tick: MONTH.format(start),
label: `${SHORT.format(start)} – ${SHORT.format(end)}`,
easy: 0,
medium: 0,
hard: 0,
});
}
}
for (const day of days) {
const date = utcDate(day.date);
if (date.getUTCFullYear() !== year) continue;
const i = grain === "month" ? date.getUTCMonth() : Math.floor(((date.getTime() - jan1) / DAY_MS + lead) / 7);
const bucket = out[i];
if (!bucket) continue;
for (const d of DIFFICULTIES) bucket[d] += day[mode][d];
}
return out;
}
export default function SubmissionChart() {
const load = useStats();
const [mode, setMode] = useState<Mode>("solved");
const [grain, setGrain] = useState<Grain>("month");
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-40 w-full" />
</CardContent>
</Card>
);
}
const year = utcDate(load.stats.year.at(-1)?.date ?? "1970-01-01").getUTCFullYear();
const data = buckets(load.stats.year, year, grain, mode);
const totals = Object.fromEntries(
DIFFICULTIES.map((d) => [d, data.reduce((n, b) => n + b[d], 0)]),
);
const peak = Math.max(...data.map((b) => b.easy + b.medium + b.hard));
// A round ceiling for the one labelled gridline, as LeetCode draws it.
const top = peak <= 5 ? 5 : Math.ceil(peak / 10) * 10;
return (
<Card>
<CardHeader className="flex flex-wrap items-center justify-between gap-2">
<ToggleGroup
type="single"
size="sm"
spacing={1}
value={mode}
onValueChange={(value: string) => value && setMode(value as Mode)}
className="rounded-full bg-muted p-0.5"
aria-label="Count"
>
<ToggleGroupItem value="solved" className="h-7 rounded-full px-2.5 text-xs">
Solved
</ToggleGroupItem>
<ToggleGroupItem value="attempts" className="h-7 rounded-full px-2.5 text-xs">
Submissions
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center">
<ToggleGroup
type="single"
size="sm"
spacing={1}
value={grain}
onValueChange={(value: string) => value && setGrain(value as Grain)}
className="rounded-full bg-muted p-0.5"
aria-label="Bucket size"
>
<ToggleGroupItem value="month" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per month">
M
</ToggleGroupItem>
<ToggleGroupItem value="week" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per week">
W
</ToggleGroupItem>
</ToggleGroup>
</div>
</CardHeader>
<CardContent className="flex-1">
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
<BarChart accessibilityLayer data={data} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="index"
type="category"
tickLine={false}
axisLine={false}
tickMargin={6}
ticks={[0, data.length - 1]}
interval={0}
// First label anchored at its start, last at its end, so neither
// is clipped when a week band is only a few pixels wide.
tick={({ x, y, payload }) => (
<text
x={x}
y={y}
dy={10}
textAnchor={payload.value === 0 ? "start" : "end"}
className="fill-muted-foreground"
>
{data[Number(payload.value)]?.tick}
</text>
)}
/>
<YAxis
orientation="right"
domain={[0, top]}
ticks={[0, top]}
tickLine={false}
axisLine={false}
width={28}
allowDecimals={false}
/>
<ChartTooltip
cursor={false}
// Recharts applies itemSorter only in its default tooltip; this
// content gets the stack's bottom-up order, so flip it to read
// top-down, Easy first, like the bars.
content={({ active, label, payload }) => (
<ChartTooltipContent
active={active}
label={label}
payload={[...(payload ?? [])].reverse()}
labelFormatter={(_, items) => items[0]?.payload?.label}
indicator="dot"
/>
)}
/>
{(["hard", "medium", "easy"] as const).map((d) => (
<Bar
key={d}
dataKey={d}
stackId="solved"
fill={`var(--color-${d})`}
radius={2}
maxBarSize={grain === "month" ? 10 : 5}
/>
))}
</BarChart>
</ChartContainer>
</CardContent>
<CardFooter className="gap-4 text-sm">
{DIFFICULTIES.map((d) => (
<span key={d} className="flex items-baseline gap-1">
<span className={DIFFICULTY_TEXT[d]}>{chartConfig[d].label}</span>
<span className="text-muted-foreground tabular-nums">{totals[d]}</span>
</span>
))}
<span className="ml-auto text-muted-foreground tabular-nums">{year}</span>
</CardFooter>
</Card>
);
}
-165
View File
@@ -1,165 +0,0 @@
/**
* The last six months of attempts — LeetCode's calendar heatmap, halved to
* share its row with the submissions chart: one cell per day, weeks as
* columns, each month its own block, and the window's attempt count, active
* days and longest streak above it. The figures are counted from exactly the
* cells drawn, so they cannot disagree with the grid. The colour ramp is the
* README/email heatmap's (theme.css `--heat-0..4`, level = attempts capped at
* 4) and rows start on Monday like it, so the three heatmaps read the same.
* Drawn as SVG in a viewBox, so it scales to its column instead of scrolling.
*/
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { type Day, useStats, utcDate } from "../lib/stats";
export const client = "load";
const MONTHS = 6;
const CELL = 10;
const STEP = 13; // cell + gap
const MONTH_GAP = 8;
const LABEL_H = 16;
const HEAT = ["var(--heat-0)", "var(--heat-1)", "var(--heat-2)", "var(--heat-3)", "var(--heat-4)"];
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
interface Cell {
x: number;
y: number;
date: Date;
attempts: number;
passes: number;
}
interface Block {
label: string;
x: number;
width: number;
cells: Cell[];
}
/**
* Month blocks laid left to right over the last MONTHS calendar months. Within
* a block a day sits at row = weekday (Mon = 0) and column = its week counted
* from the 1st, so a month starting on a Thursday opens with a short column.
*/
function layout(year: Day[]): { blocks: Block[]; width: number } {
const last = utcDate(year.at(-1)?.date ?? "1970-01-01");
const from = Date.UTC(last.getUTCFullYear(), last.getUTCMonth() - (MONTHS - 1), 1);
const blocks: Block[] = [];
let x = 0;
for (const day of year) {
const date = utcDate(day.date);
if (date.getTime() < from) continue;
let block = blocks.at(-1);
if (date.getUTCDate() === 1 || !block) {
if (block) x += block.width + MONTH_GAP;
block = { label: MONTH.format(date), x, width: 0, cells: [] };
blocks.push(block);
}
const first = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
const index = (first.getUTCDay() + 6) % 7 + date.getUTCDate() - 1;
const col = Math.floor(index / 7);
block.cells.push({
x: x + col * STEP,
y: (index % 7) * STEP,
date,
attempts: day.attempts.easy + day.attempts.medium + day.attempts.hard,
passes: day.passes,
});
block.width = col * STEP + CELL;
}
const end = blocks.at(-1);
return { blocks, width: end ? end.x + end.width : 0 };
}
export default function SubmissionHeatmap() {
const load = useStats();
const hover = useHoverTip<HTMLDivElement>();
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-40 w-full" />
</CardContent>
</Card>
);
}
const { blocks, width } = layout(load.stats.year);
const cells = blocks.flatMap((b) => b.cells);
let run = 0;
let streak = 0;
for (const c of cells) {
run = c.attempts > 0 ? run + 1 : 0;
streak = Math.max(streak, run);
}
const height = 7 * STEP - (STEP - CELL) + LABEL_H;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-baseline gap-2">
<span className="font-semibold text-2xl tabular-nums">{cells.reduce((n, c) => n + c.attempts, 0)}</span>
<span className="font-normal text-muted-foreground text-sm">attempts in the past {MONTHS} months</span>
</CardTitle>
<CardDescription className="flex gap-4">
<span>
Active days:{" "}
<span className="text-foreground tabular-nums">{cells.filter((c) => c.attempts > 0).length}</span>
</span>
<span>
Max streak: <span className="text-foreground tabular-nums">{streak}</span>
</span>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-1 items-center">
<div ref={hover.ref} className="relative w-full" onPointerLeave={hover.hide}>
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-auto w-full"
role="img"
aria-label={`Attempts per day over the past ${MONTHS} months`}
>
{blocks.map((block) => (
<g key={`${block.label}-${block.x}`}>
{block.cells.map((cell) => (
<rect
key={cell.x * 1000 + cell.y}
x={cell.x}
y={cell.y}
width={CELL}
height={CELL}
rx={2}
fill={HEAT[Math.min(cell.attempts, 4)]}
onPointerMove={(event) =>
hover.show(
event,
<>
<div className="font-medium">{DAY.format(cell.date)}</div>
<TipRow color={HEAT[Math.min(cell.attempts, 4)]!} label="Attempts" value={cell.attempts} />
<TipRow color="var(--chart-4)" label="Passed" value={cell.passes} />
</>,
)
}
/>
))}
<text
x={block.x + block.width / 2}
y={height - 3}
textAnchor="middle"
className="fill-muted-foreground text-[9px]"
>
{block.label}
</text>
</g>
))}
</svg>
{hover.node}
</div>
</CardContent>
</Card>
);
}
-139
View File
@@ -1,139 +0,0 @@
/**
* The campaign's topics as packed bubbles — LeetCode's "All Topics" view,
* over our curriculum instead of LeetCode's tags. A bubble's area is the
* topic's problem count; it fills from the bottom like a glass as problems
* are solved, and turns solid once every one is. A topic the schedule has not
* reached yet is dimmed. Packing is d3-hierarchy's front-chain (packSiblings,
* largest first, so the layout is deterministic), drawn as SVG in a viewBox:
* Recharts has no packed layout.
*/
import { packEnclose, packSiblings } from "d3-hierarchy";
import { useId } from "react";
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { type Stats, useStats } from "../lib/stats";
import { cn } from "../lib/utils";
export const client = "load";
const PAD = 2; // gap between bubbles, in layout units
const UNIT = 12; // radius of a one-problem bubble
type Topic = Stats["topics"][number];
type Bubble = Topic & { x: number; y: number; r: number };
/** Split a name across lines of at most ~10 characters, never breaking a word. */
function lines(name: string): string[] {
const out: string[] = [];
for (const word of name.split(" ")) {
const last = out.at(-1);
if (last !== undefined && `${last} ${word}`.length <= 10) out[out.length - 1] = `${last} ${word}`;
else out.push(word);
}
return out;
}
export default function TopicBubbles() {
const load = useStats();
const hover = useHoverTip<HTMLDivElement>();
const clip = useId().replace(/:/g, "");
if (load.phase !== "ready") {
return (
<Card>
<CardHeader>
<CardTitle>Topics</CardTitle>
</CardHeader>
<CardContent>
<StatsPending load={load} className="aspect-square w-full" />
</CardContent>
</Card>
);
}
const topics = load.stats.topics.filter((t) => t.total > 0);
const circles: Bubble[] = topics
.slice()
.sort((a, b) => b.total - a.total || a.issue - b.issue)
.map((t) => ({ ...t, x: 0, y: 0, r: UNIT * Math.sqrt(t.total) + PAD }));
packSiblings(circles);
const bound = packEnclose(circles) ?? { x: 0, y: 0, r: 1 };
const solved = topics.reduce((n, t) => n + t.done, 0);
const total = topics.reduce((n, t) => n + t.total, 0);
return (
<Card>
<CardHeader>
<CardTitle>Topics</CardTitle>
<CardDescription>
{solved} of {total} problems across {topics.length} topics · size is problem count, fill is solved
</CardDescription>
</CardHeader>
<CardContent>
<div ref={hover.ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hover.hide}>
<svg
viewBox={`${bound.x - bound.r} ${bound.y - bound.r} ${bound.r * 2} ${bound.r * 2}`}
className="h-auto w-full"
role="img"
aria-label="Campaign topics sized by problem count and filled by problems solved"
>
{circles.map((c) => {
const r = c.r - PAD;
const ratio = c.total > 0 ? c.done / c.total : 0;
const complete = c.done >= c.total;
const words = lines(c.name);
const size = Math.min(r / 3.2, (1.7 * r) / Math.max(...words.map((w) => w.length)));
const id = `${clip}-${c.issue}`;
return (
<g
key={c.issue}
className={cn("transition-opacity hover:opacity-90", !c.taught && "opacity-50")}
onPointerMove={(event) =>
hover.show(
event,
<>
<div className="font-medium">{c.name}</div>
<TipRow color="var(--chart-4)" label="Solved" value={`${c.done}/${c.total}`} />
{!c.taught && <div className="text-muted-foreground">Not reached yet</div>}
</>,
)
}
>
<clipPath id={id}>
<circle cx={c.x} cy={c.y} r={r} />
</clipPath>
<circle cx={c.x} cy={c.y} r={r} className="fill-muted stroke-border" strokeWidth={0.5} />
<rect
x={c.x - r}
y={c.y + r - 2 * r * ratio}
width={2 * r}
height={2 * r * ratio}
clipPath={`url(#${id})`}
fill="var(--chart-4)"
fillOpacity={complete ? 1 : 0.35}
/>
<text
x={c.x}
y={c.y - ((words.length - 1) * size * 1.15) / 2}
textAnchor="middle"
dominantBaseline="central"
clipPath={`url(#${id})`}
className={cn("pointer-events-none select-none", complete ? "fill-background" : "fill-foreground")}
style={{ fontSize: size }}
>
{words.map((w, i) => (
<tspan key={`${i}-${w}`} x={c.x} dy={i === 0 ? 0 : size * 1.15}>
{w}
</tspan>
))}
</text>
</g>
);
})}
</svg>
{hover.node}
</div>
</CardContent>
</Card>
);
}
-14
View File
@@ -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;
};
}
-91
View File
@@ -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());
}
-88
View File
@@ -1,88 +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(() => {
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
@@ -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] };
}
}
-7
View File
@@ -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));
}
-28
View File
@@ -1,28 +0,0 @@
{
"name": "docs",
"private": true,
"type": "module",
"scripts": {
"dev": "blume dev",
"build": "blume build",
"doctor": "blume doctor"
},
"dependencies": {
"blume": "^2.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-hierarchy": "^3.1.2",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "3.8.0",
"tailwind-merge": "^3.7.0"
},
"devDependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@types/d3-hierarchy": "^3.1.7",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0"
}
}
-120
View File
@@ -1,120 +0,0 @@
---
/**
* `/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 { withBase } from "blume/components/islands/base-path.ts";
import redirects from "../redirects";
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}`;
// LC number -> its docs page route, off sync's `/<num>` short links — the
// page itself, not the redirect, so a click is one hop. A problem with no page
// (none yet) links to its issue instead.
const pages = new Map(redirects.map((r) => [Number(r.from.slice(1)), r.to]));
const problemHref = (lc: number, issue: number) => {
const route = pages.get(lc);
return route ? withBase(route) : issueUrl(issue);
};
// 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">
<span class="mr-2 font-mono text-xs uppercase tracking-wide">{p.set}</span>
<a class="text-foreground no-underline hover:underline" href={problemHref(p.lc, p.issue)}>
{p.title}
</a>
{DIFFICULTY[p.difficulty] && (
<span
class:list={[
"ml-2 inline-block rounded-full border px-2 py-px align-middle font-mono text-[11px] leading-4",
DIFFICULTY[p.difficulty]!.class,
]}
>
{DIFFICULTY[p.difficulty]!.label}
</span>
)}
</span>
</li>
))}
</ul>
</article>
</li>
))
}
</ol>
</section>
</PageLayout>
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff. Show more