Compare commits

...
223 changed files with 6747 additions and 763 deletions

No files matched your search

+7 -9
View File
@@ -1,11 +1,11 @@
--- ---
name: problem-approach name: problem-approach
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. 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.
--- ---
# Problem approach diagrams # Problem approach diagrams
Every solution page (`docs/(<phase>)/<topic>/(problems)/<num>_<slug>.mdx`) has a Every solution page (`apps/docs/content/(<phase>)/<topic>/(problems)/<num>_<slug>.mdx`) has a
hand-owned section before the script-owned one: 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): 1. **Find the pages** (numbered pages with no `## Approach` heading):
```sh ```sh
grep -rL '^## Approach' docs --include='[0-9]*_*.mdx' grep -L '^## Approach' apps/docs/content/*/*.mdx
``` ```
2. **For each page**, read `## Solution` and write the diagram from *that code* — the 2. **For each page**, read `## Solution` and write the diagram from *that code* — the
@@ -47,13 +47,11 @@ next `work/` push.
```` ````
4. **Check** each diagram by hand against the syntax rules below (the docs build does 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). The site is built not parse Mermaid — a broken diagram fails only in the browser), then run the
from prdlk/cs, which reads `docs/` over HTTP, so preview unpushed pages there docs build once at the end and review the pages in `bun run dev`:
against this checkout (its `README` names the local-content switch), then build
once at the end:
```sh ```sh
cd ../cs && bunx blume build --isolated && rm -rf .blume-verify cd apps/docs && bunx blume build --isolated && rm -rf .blume-verify
``` ```
Pages with two languages (`<CodeGroup>`) get **one** diagram, traced from the Pages with two languages (`<CodeGroup>`) get **one** diagram, traced from the
@@ -61,7 +59,7 @@ Python version. Pages with only JavaScript trace the JavaScript.
## Diagram conventions ## Diagram conventions
Reference page: `docs/(01-linear-structures)/two-pointers/(problems)/15_3sum.mdx`. Every existing diagram Reference page: `apps/docs/content/(array)/15-3sum.mdx`. Every existing diagram
follows this shape: follows this shape:
```mermaid ```mermaid
+38 -4
View File
@@ -5,19 +5,53 @@ on:
branches: [main] branches: [main]
workflow_dispatch: 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: permissions:
contents: read contents: read
pages: write
id-token: write
# The Worker deploy is idempotent (wrangler uploads the whole bundle), so on a # 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. # burst of pushes only the last one matters: cancel-in-progress is correct for
# both the Pages build and the Worker job.
concurrency: concurrency:
group: deploy group: deploy
cancel-in-progress: true cancel-in-progress: true
jobs: 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 # 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. # is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit), # Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit),
+3
View File
@@ -37,6 +37,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
/CLAUDE.md /CLAUDE.md
.blume/
/apps/docs/docs/.wrangler/state/v3
.blume-verify/
# api worker # api worker
apps/api/.dev.vars apps/api/.dev.vars
+15 -14
View File
@@ -2,7 +2,7 @@
## Project Overview ## 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/`, 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. 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.
## Architecture & Data Flow ## Architecture & Data Flow
@@ -10,8 +10,7 @@ Three owners, one direction of truth:
```mermaid ```mermaid
flowchart LR flowchart LR
work[work/ solutions] -->|bun run sync| docs[docs/*.mdx + docs/site.json] work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
docs -->|mdxRemote + site.json fetch| Site[prdlk/cs docs site]
work -->|close-solved.yml| PI[problem issues] work -->|close-solved.yml| PI[problem issues]
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)] work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
PI -->|close-topics.yml| TI[topic + Review N issues] PI -->|close-topics.yml| TI[topic + Review N issues]
@@ -39,7 +38,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). - **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`. - **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. - **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 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()`. - **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.
- **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 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). - **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).
@@ -49,10 +48,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 | | `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/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 |
| `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/docs/` | Blume site (`blume.config.ts`, `content/`, `pages/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/(problems)/<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, 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). The `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order; every topic meta is `display: "page"`, so a topic is one sidebar row whose panel holds its landing page and a "Problems" section. 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 card on the Appendix glossary `/appendix#<slug>` (outlined; LeetCode's problem list if the tag has no term, 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 (a landing's: the topic `meta.ts` icon read off `data.navigation`; an Appendix term's: its `icon` in `appendix.ts`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a landing or a term 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 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 and the Appendix page (so badges, backlinks and term cards cannot disagree) and the map by sync. Every other tag a problem carries is ONE hand-written table, `apps/docs/appendix.ts` (`APPENDIX`: slug → `{ name, icon, category, definition }`), rendered by ONE custom page, `pages/appendix.astro` — a glossary with an A–Z letter index and a filter box (an `<appendix-filter>` custom element over static markup; no JS, the full list), one card per term (icon tinted the tag's colour, category, definition, LeetCode list link, and a `<details>` "Solved" list of the problem pages whose badge `tagTarget()` sends there, paged `PAGE_SIZE` (5) rows at a time by numbered buttons under it — a `<solved-pages>` custom element; without JS every row shows and the buttons stay hidden), each card `id`'d by slug and highlighted as `:target`. It is no content folder, so it enters neither the sidebar tree nor prev/next: `navigation.featured` pins it above the tree. Sync writes nothing for it — it warns about a carried tag with no term, a term whose tag a topic now teaches (delete it), and a term `icon` that is a `TOPIC_ICONS` value or another term's. The former per-term pages `/appendix/<slug>` are exact redirects to `/appendix#<slug>` in `blume.config.ts`, generated from `APPENDIX`'s keys. 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 aLine truncated
| `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) | | `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 (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) | | `.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/` | `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 | | `.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 |
## Development Commands ## Development Commands
@@ -72,11 +71,12 @@ bun run test # run ONE solution against LeetCode's judge (not a test
# most recently tested first, stamped in the db on exit 0) # most recently tested first, stamped in the db on exit 0)
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
# hidden; last_submitted stamped in the db on exit 0) # hidden; last_submitted stamped in the db on exit 0)
bun run sync # work/1 → docs/ pages + docs/site.json (folder meta + /<num> short bun run sync # work/1 → apps/docs/content/ pages + apps/docs/redirects.ts (/<num> short
# links; re-solves are never published) # links; re-solves are never published)
bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page
# (channel search scraped, no API key; --write adds the # (channel search scraped, no API key; --write adds the
# `video:` key; "no NeetCode match" pages stay untouched) # `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-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
bun run close-topics -- --dry-run bun run close-topics -- --dry-run
bun run release-topics -- --dry-run # prints every would-be topic release body bun run release-topics -- --dry-run # prints every would-be topic release body
@@ -113,20 +113,21 @@ 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/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/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/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`): `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/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>/`, 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). The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "page"`); 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 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 — 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/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/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` — 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. - `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 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. - `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; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones (house style) — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume turns every tsconfig `paths` target into a Vite alias AND a dev dep-scan glob (`<target>/**/*.astro`…), so `paths` maps only `@/components`, `@/lib`, `@/hooks` — each bare and `/*`, since `components.json` names the bare forms — never `@/*` → `./*`: that glob reaches through `node_modules/blume`, where Vite's scanner reads a `<script>` inside a `RootLayout.astro` comment as a tag and `blume dev` skips dependency pre-bundling. 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`).
- 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. - 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 ## 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`. - **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. 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.) - **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.
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions. - Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
## Testing & QA ## 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`. - 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. - 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: 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. - 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).
+4 -4
View File
@@ -21,14 +21,14 @@
* tagged commit — the last main commit before the finish. Problems still open * 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 * 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. * optional problem is tracked by its issue (or its Review day), not here.
* The docs site (prdlk/cs `lib/releases.ts`) reads these `### Closed …` * apps/docs/lib/releases.ts reads these `### Closed …` tables back for the
* tables back for its `/changelog` timeline, so their row shape is its contract. * docs `/changelog` timeline, so their row shape is its contract.
* *
* The tag is annotated and backdated (tagger date = finish), so * The tag is annotated and backdated (tagger date = finish), so
* `git tag --sort=taggerdate` reads as the campaign's history even for tags * `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 * 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 * (the API has no field for it), so the docs changelog dates each entry from
* this timever tag instead (prdlk/cs `lib/releases.ts`) * this timever tag instead (apps/docs/lib/releases.ts)
* — the tag format is that contract. * — the tag format is that contract.
* *
* Reconciles instead of reacting: every finished topic or review without a * 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 === "1" ||
process.env.DRY_RUN === "true"; process.env.DRY_RUN === "true";
/** The docs site (prdlk/cs `blume.config.ts` `deployment.site`); it publishes sync's `/<num>` short links. */ /** The docs site (apps/docs/blume.config.ts `deployment.site`); sync publishes `/<num>` short links. */
const DOCS_SITE = "https://lc.prd.lk"; const DOCS_SITE = "https://lc.prd.lk";
const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668"; const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668";
+277 -109
View File
@@ -1,13 +1,6 @@
#!/usr/bin/env bun #!/usr/bin/env bun
/** /**
* Sync work/1 leetcode solutions into docs/ pages, plus docs/site.json. * Sync work/1 leetcode solutions into apps/docs/content/ pages.
*
* 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, * - New problems get a full page (frontmatter, warning, examples,
* constraints, solution) in the gold-standard format. * constraints, solution) in the gold-standard format.
@@ -22,9 +15,9 @@
* `topicTags` once — when the page is created, or on any later run that * `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 * 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 * new makes no request. Delete the key to refetch it. The page itself
* renders no badges: the site's MDX `wrapper` (prdlk/cs * renders no badges: the docs MDX `wrapper`
* `components/ProblemBadges.astro`) turns `topics:` and the `video:` key * (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
* `videos.ts` writes into the badge row under the description. * `video:` key `videos.ts` writes into the badge row under the description.
* - Every planned page (a problem the README plan lists) carries its GitHub * - Every planned page (a problem the README plan lists) carries its GitHub
* problem issue as frontmatter `issue:` (the URL of the `problem`-labelled * problem issue as frontmatter `issue:` (the URL of the `problem`-labelled
* issue titled `LC <num> · …`), rendered by the same wrapper as a GitHub * issue titled `LC <num> · …`), rendered by the same wrapper as a GitHub
@@ -34,11 +27,18 @@
* (never on a sync with nothing new); offline, or with no `gh` auth, the * (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 * page is written without it and the next sync retries. Delete the key to
* refetch it. * refetch it.
* - Every tag a page carries that no campaign topic teaches (not a key of
* `apps/docs/topic-landings.ts`) belongs in the Appendix glossary,
* `apps/docs/appendix.ts`, which is hand-written: sync only checks it,
* warning about a carried tag with no term (its badge falls back to
* LeetCode's list), a term whose tag a topic now teaches, and a term icon
* that is missing, a topic's (`TOPIC_ICONS`), or another term's — every
* badge linking to the term shows it.
* *
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`). * 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 * ordered by topic number via a script-owned `meta.ts`). A problem outside the
* data-structure CATEGORY its file sits under in * plan (warm-ups) has no topic, so it groups under the data-structure CATEGORY
* `work/1/<Difficulty>/<Category>/` instead. * its file sits under in `work/1/<Difficulty>/<Category>/` instead.
* *
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`. * Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
* The phase folder is a Blume `(group)` — a sidebar section adding no route * The phase folder is a Blume `(group)` — a sidebar section adding no route
@@ -49,33 +49,31 @@
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either, * `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 * 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 * 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 * an `index.mdx`, and never removes a folder that still holds one. A phase
* unplanned category group has no phase and sits at the docs/ root. * 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
* `docs/site.json` is wholly script-owned and rewritten whenever it differs: * group has no phase and sits at the content root.
* - `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 * 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 * of problems whose page already exists (see work.ts), and a fresh scaffold
* there would otherwise wipe the page's `## Solution`. * 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 } from "node:fs/promises"; import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
import { basename, dirname, join } from "node:path"; import { basename, dirname, join } from "node:path";
import { APPENDIX } from "../docs/appendix.ts";
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
import { github } from "./github.ts"; import { github } from "./github.ts";
const ROOT = join(import.meta.dir, "..", ".."); const ROOT = join(import.meta.dir, "..", "..");
const WORK = join(ROOT, "work", "1"); const WORK = join(ROOT, "work", "1");
const DOCS = join(ROOT, "docs"); const DOCS = join(ROOT, "apps", "docs", "content");
const MANIFEST = join(DOCS, "site.json"); const REDIRECTS = join(ROOT, "apps", "docs", "redirects.ts");
type Lang = "js" | "py"; type Lang = "js" | "py";
@@ -106,11 +104,15 @@ interface Solution {
// ── work/ parsing ──────────────────────────────────────────────── // ── work/ parsing ────────────────────────────────────────────────
/** Split a source file into (header comment text, code). */ /** Split a source file into (header comment text, code). */
function splitSource(src: string, lang: Lang): { header: string; code: string } { function splitSource(
src: string,
lang: Lang,
): { header: string; code: string } {
let header: string, code: string; let header: string, code: string;
if (lang === "js") { if (lang === "js") {
const end = src.indexOf("*/"); const end = src.indexOf("*/");
if (!src.trimStart().startsWith("/*") || end === -1) throw new Error("missing /* header */"); if (!src.trimStart().startsWith("/*") || end === -1)
throw new Error("missing /* header */");
header = src header = src
.slice(src.indexOf("/*") + 2, end) .slice(src.indexOf("/*") + 2, end)
.split("\n") .split("\n")
@@ -142,7 +144,11 @@ function paragraphs(lines: string[]): string[] {
flush(); flush();
continue; continue;
} }
if (/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(text)) { if (
/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(
text,
)
) {
flush(); flush();
cur = text; cur = text;
} else { } else {
@@ -160,7 +166,10 @@ function parseHeader(header: string): Header {
const num = Number.parseInt(titleLine, 10); const num = Number.parseInt(titleLine, 10);
const title = titleLine.replace(/^\d+\.\s*/, ""); const title = titleLine.replace(/^\d+\.\s*/, "");
const difficulty = const difficulty =
lines.find((l) => l.trim().startsWith("Difficulty:"))?.split(":")[1]?.trim() ?? ""; lines
.find((l) => l.trim().startsWith("Difficulty:"))
?.split(":")[1]
?.trim() ?? "";
// Body: everything after the ───── separator. // Body: everything after the ───── separator.
const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim())); const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim()));
@@ -290,10 +299,18 @@ async function readmeTopics(): Promise<Map<number, Topic>> {
const p = line.match(/^## Phase ([IVX]+) — (.+?) · /); const p = line.match(/^## Phase ([IVX]+) — (.+?) · /);
if (p) { if (p) {
const name = p[2]!.replaceAll("&amp;", "&"); const name = p[2]!.replaceAll("&amp;", "&");
phase = { order: ROMAN.indexOf(p[1]!) + 1, name: PHASE_ALIASES[name] ?? name }; phase = {
order: ROMAN.indexOf(p[1]!) + 1,
name: PHASE_ALIASES[name] ?? name,
};
} }
const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/); const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/);
if (t) topic = { order: Number(t[1]), name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim(), phase }; if (t)
topic = {
order: Number(t[1]),
name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim(),
phase,
};
if (!topic) continue; if (!topic) continue;
const n = line.match(/<td align="center">(\d+)<\/td>/); const n = line.match(/<td align="center">(\d+)<\/td>/);
if (n && !map.has(Number(n[1]))) map.set(Number(n[1]), topic); if (n && !map.has(Number(n[1]))) map.set(Number(n[1]), topic);
@@ -329,34 +346,38 @@ function phaseDir(phase: Phase): string {
return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`; return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`;
} }
/** A sidebar group's Blume folder meta, as `docs/site.json` carries it. */ /**
interface FolderMeta { * Blume `(group)` meta for a phase: the overview page's heading, in phase
title: string; * order. Written only when the folder has none — the title is hand-owned after
order: number; * that. Nothing is lost by never rewriting it: the order and name it is built
icon?: string; * from are also the folder name (`phaseDir`), so a README change to either
display?: "page"; * 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`;
} }
/** /**
* Folder meta for a phase `(group)`: the overview page's heading, `N. Name`, * Blume folder meta for a topic: exact label (the folder slug loses `/` and
* in phase order. Derived like everything in the manifest, so a README rename * case), curriculum order, the sidebar icon, and `display: "page"` so the
* renames the heading — and lands in a new folder (`phaseDir`) anyway. * 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 phaseMeta(phase: Phase): FolderMeta { function groupMeta(topic: Topic): string {
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]; const icon = TOPIC_ICONS[topic.name];
if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`); if (!icon)
return { title: topic.name, order: topic.order ?? 0, ...(icon ? { icon } : {}), display: "page" }; 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`;
} }
// ── LeetCode topic tags ────────────────────────────────────────── // ── LeetCode topic tags ──────────────────────────────────────────
@@ -382,17 +403,24 @@ async function fetchTopicTags(sol: Solution): Promise<TopicTag[] | undefined> {
referer: `https://leetcode.com/problems/${sol.slug}/`, referer: `https://leetcode.com/problems/${sol.slug}/`,
}, },
body: JSON.stringify({ body: JSON.stringify({
query: "query topicTags($titleSlug: String!) { question(titleSlug: $titleSlug) { topicTags { name slug } } }", query:
"query topicTags($titleSlug: String!) { question(titleSlug: $titleSlug) { topicTags { name slug } } }",
variables: { titleSlug: sol.slug }, variables: { titleSlug: sol.slug },
}), }),
}); });
if (!res.ok) throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`); if (!res.ok)
const data = (await res.json()) as { data?: { question?: { topicTags?: TopicTag[] } | null } }; throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`);
const data = (await res.json()) as {
data?: { question?: { topicTags?: TopicTag[] } | null };
};
const tags = data.data?.question?.topicTags; const tags = data.data?.question?.topicTags;
if (!Array.isArray(tags)) throw new Error("POST /graphql -> no such question"); if (!Array.isArray(tags))
throw new Error("POST /graphql -> no such question");
return tags.map(({ name, slug }) => ({ name, slug })); return tags.map(({ name, slug }) => ({ name, slug }));
} catch (err) { } catch (err) {
console.warn(`no topics for ${sol.num}.${sol.slug}: ${err instanceof Error ? err.message : err}`); console.warn(
`no topics for ${sol.num}.${sol.slug}: ${err instanceof Error ? err.message : err}`,
);
return undefined; return undefined;
} }
} }
@@ -400,7 +428,30 @@ async function fetchTopicTags(sol: Solution): Promise<TopicTag[] | undefined> {
/** The frontmatter `topics:` block, one flow mapping per tag (JSON strings are valid YAML). */ /** The frontmatter `topics:` block, one flow mapping per tag (JSON strings are valid YAML). */
function topicsYaml(tags: TopicTag[]): string { function topicsYaml(tags: TopicTag[]): string {
if (tags.length === 0) return "topics: []"; if (tags.length === 0) return "topics: []";
return ["topics:", ...tags.map((t) => ` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`)].join("\n"); 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 ──────────────────────────────────────── // ── GitHub problem issues ────────────────────────────────────────
@@ -418,7 +469,9 @@ function problemIssues(): Promise<Map<number, string> | undefined> {
try { try {
const gh = await github(); const gh = await github();
const urls = new Map<number, string>(); const urls = new Map<number, string>();
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=all`)) { for await (const raw of gh.list(
`/repos/${gh.repo}/issues?labels=problem&state=all`,
)) {
if (!raw || typeof raw !== "object" || "pull_request" in raw) continue; if (!raw || typeof raw !== "object" || "pull_request" in raw) continue;
if (!("title" in raw) || typeof raw.title !== "string") continue; if (!("title" in raw) || typeof raw.title !== "string") continue;
if (!("html_url" in raw) || typeof raw.html_url !== "string") continue; if (!("html_url" in raw) || typeof raw.html_url !== "string") continue;
@@ -427,7 +480,9 @@ function problemIssues(): Promise<Map<number, string> | undefined> {
} }
return urls; return urls;
} catch (err) { } catch (err) {
console.warn(`no problem issues: ${err instanceof Error ? err.message : err}`); console.warn(
`no problem issues: ${err instanceof Error ? err.message : err}`,
);
return undefined; return undefined;
} }
})(); })();
@@ -444,7 +499,10 @@ async function issueUrl(sol: Solution): Promise<string | undefined> {
if (!topics.has(sol.num)) return undefined; if (!topics.has(sol.num)) return undefined;
const urls = await problemIssues(); const urls = await problemIssues();
const url = urls?.get(sol.num); const url = urls?.get(sol.num);
if (urls && !url) console.warn(`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`); if (urls && !url)
console.warn(
`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`,
);
return url; return url;
} }
@@ -454,13 +512,15 @@ async function issueUrl(sol: Solution): Promise<string | undefined> {
function identifiers(sol: Solution): Set<string> { function identifiers(sol: Solution): Set<string> {
const ids = new Set<string>(["n", "m", "k"]); const ids = new Set<string>(["n", "m", "k"]);
for (const code of Object.values(sol.code)) { for (const code of Object.values(sol.code)) {
for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g)) ids.add(m[1]!); for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g))
ids.add(m[1]!);
for (const m of code.matchAll(/def \w+\(self,?\s*([^)]*)\)/g)) for (const m of code.matchAll(/def \w+\(self,?\s*([^)]*)\)/g))
for (const arg of m[1]!.split(",")) for (const arg of m[1]!.split(","))
if (arg.trim()) ids.add(arg.split(":")[0]!.trim()); if (arg.trim()) ids.add(arg.split(":")[0]!.trim());
for (const m of code.matchAll(/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g)) for (const m of code.matchAll(
for (const arg of m[2]!.split(",")) /(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g,
if (arg.trim()) ids.add(arg.trim()); ))
for (const arg of m[2]!.split(",")) if (arg.trim()) ids.add(arg.trim());
} }
return ids; return ids;
} }
@@ -517,7 +577,11 @@ function backtickify(text: string, ids: Set<string>): string {
while (j > i && /^[+\-*/=%]$/.test(toks[j]!.core)) j--; while (j > i && /^[+\-*/=%]$/.test(toks[j]!.core)) j--;
const span = toks const span = toks
.slice(i, j + 1) .slice(i, j + 1)
.map((tok, idx, arr) => (idx === arr.length - 1 ? `${tok.pre}${tok.core}` : `${tok.pre}${tok.core}${tok.post}`)) .map((tok, idx, arr) =>
idx === arr.length - 1
? `${tok.pre}${tok.core}`
: `${tok.pre}${tok.core}${tok.post}`,
)
.join(" "); .join(" ");
out.push(`\`${span}\`${toks[j]!.post}`); out.push(`\`${span}\`${toks[j]!.post}`);
i = j + 1; i = j + 1;
@@ -545,14 +609,22 @@ function warningText(h: Header): string {
return ( return (
h.statement h.statement
.slice(1) .slice(1)
.find((p) => /^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(p)) ?? "" .find((p) =>
/^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(
p,
),
) ?? ""
); );
} }
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */ /** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
const PAGE_ICON = "dot"; const PAGE_ICON = "dot";
function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string | undefined): string { function renderPage(
sol: Solution,
tags: TopicTag[] | undefined,
issue: string | undefined,
): string {
const h = sol.header; const h = sol.header;
const ids = identifiers(sol); const ids = identifiers(sol);
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? ""; const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
@@ -561,7 +633,9 @@ function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string |
parts.push( parts.push(
"---", "---",
`title: '${h.num}. ${h.title.replaceAll("'", "''")}'`, `title: '${h.num}. ${h.title.replaceAll("'", "''")}'`,
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`, /[:#]/.test(description)
? `description: '${description.replaceAll("'", "''")}'`
: `description: ${description}`,
`icon: ${PAGE_ICON}`, `icon: ${PAGE_ICON}`,
...(tags ? [topicsYaml(tags)] : []), ...(tags ? [topicsYaml(tags)] : []),
...(issue ? [`issue: ${JSON.stringify(issue)}`] : []), ...(issue ? [`issue: ${JSON.stringify(issue)}`] : []),
@@ -583,7 +657,8 @@ function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string |
parts.push(`- Explanation: ${backtickify(ex.explanation[0]!, ids)}`); parts.push(`- Explanation: ${backtickify(ex.explanation[0]!, ids)}`);
else if (ex.explanation.length > 1) { else if (ex.explanation.length > 1) {
parts.push("- Explanation:"); parts.push("- Explanation:");
for (const item of ex.explanation) parts.push(` - ${backtickify(item, ids)}`); for (const item of ex.explanation)
parts.push(` - ${backtickify(item, ids)}`);
} }
parts.push(""); parts.push("");
}); });
@@ -608,7 +683,10 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
} }
const num = Number(m[1]); const num = Number(m[1]);
const slug = m[2]!; const slug = m[2]!;
const { header, code } = splitSource(await Bun.file(join(WORK, rel)).text(), lang); const { header, code } = splitSource(
await Bun.file(join(WORK, rel)).text(),
lang,
);
const parsed = parseHeader(header); const parsed = parseHeader(header);
const existing = solutions.get(num); const existing = solutions.get(num);
if (existing) { if (existing) {
@@ -657,7 +735,10 @@ async function ensureTopics(page: string, sol: Solution): Promise<string> {
if (!fm || /^topics:/m.test(fm[0])) return page; if (!fm || /^topics:/m.test(fm[0])) return page;
const tags = await fetchTopicTags(sol); const tags = await fetchTopicTags(sol);
if (!tags) return page; if (!tags) return page;
return fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) + page.slice(fm[0].length); return (
fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) +
page.slice(fm[0].length)
);
} }
/** Insert `issue:` before `sidebar:` when the frontmatter lacks it; an existing URL is kept as is. */ /** Insert `issue:` before `sidebar:` when the frontmatter lacks it; an existing URL is kept as is. */
@@ -666,7 +747,10 @@ async function ensureIssue(page: string, sol: Solution): Promise<string> {
if (!fm || /^issue:/m.test(fm[0])) return page; if (!fm || /^issue:/m.test(fm[0])) return page;
const url = await issueUrl(sol); const url = await issueUrl(sol);
if (!url) return page; if (!url) return page;
return fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\nsidebar:`) + page.slice(fm[0].length); return (
fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\nsidebar:`) +
page.slice(fm[0].length)
);
} }
/** /**
@@ -689,7 +773,9 @@ function solutionEnd(page: string, from: number): number {
// Existing docs pages, keyed by leetcode number from the frontmatter title. // Existing docs pages, keyed by leetcode number from the frontmatter title.
const pages = new Map<number, string>(); // num -> path relative to DOCS const pages = new Map<number, string>(); // num -> path relative to DOCS
for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).sort()) { for (const rel of (
await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))
).sort()) {
const text = await Bun.file(join(DOCS, rel)).text(); const text = await Bun.file(join(DOCS, rel)).text();
const t = text.match(/^title: '(\d+)\./m); const t = text.match(/^title: '(\d+)\./m);
if (t) pages.set(Number(t[1]), rel); if (t) pages.set(Number(t[1]), rel);
@@ -697,9 +783,11 @@ for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).s
const report: [string, string, string][] = []; const report: [string, string, string][] = [];
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num); const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
const folders = new Map<string, FolderMeta>(); // folder under docs/ -> its sidebar meta, for site.json 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 vacated = new Set<string>(); // folders a page moved out of; removed below if empty const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
const redirects: { from: string; to: string }[] = []; // one per page, numeric order 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
for (const sol of ordered) { for (const sol of ordered) {
const langs = Object.keys(sol.code).sort().join("+"); const langs = Object.keys(sol.code).sort().join("+");
@@ -707,14 +795,16 @@ for (const sol of ordered) {
// (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment, // (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment,
// so its index.mdx is the landing page; `(problems)` adds none) + // so its index.mdx is the landing page; `(problems)` adds none) +
// leetcode-number prefix: numeric sidebar order, prefix stripped from the URL. // 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 topic = topics.get(sol.num) ??
topicByName.get(sol.category) ?? { name: sol.category };
const dir = topicDir(topic); const dir = topicDir(topic);
if (!folders.has(dir)) folders.set(dir, groupMeta(topic)); groups.set(dir, topic);
if (topic.phase) folders.set(phaseDir(topic.phase), phaseMeta(topic.phase)); if (topic.phase) phases.set(phaseDir(topic.phase), topic.phase);
const name = `${dir}/(problems)/${sol.num}_${sol.slug}.mdx`; const name = `${dir}/${sol.num}_${sol.slug}.mdx`;
const existing = pages.get(sol.num); const existing = pages.get(sol.num);
await mkdir(join(DOCS, dirname(name)), { 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) { if (existing) {
const page = await Bun.file(join(DOCS, existing)).text(); const page = await Bun.file(join(DOCS, existing)).text();
const at = page.indexOf("## Solution"); const at = page.indexOf("## Solution");
@@ -723,8 +813,12 @@ for (const sol of ordered) {
continue; continue;
} }
const tail = page.slice(solutionEnd(page, at)); const tail = page.slice(solutionEnd(page, at));
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : ""); const body =
const next = await ensureIssue(await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol), sol); page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
const next = await ensureIssue(
await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol),
sol,
);
const actions: string[] = []; const actions: string[] = [];
if (existing !== name) { if (existing !== name) {
await rename(join(DOCS, existing), join(DOCS, name)); await rename(join(DOCS, existing), join(DOCS, name));
@@ -736,41 +830,114 @@ for (const sol of ordered) {
actions.push("updated"); actions.push("updated");
} }
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]); report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
written = next;
} else { } else {
await Bun.write(join(DOCS, name), renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol))); const page = renderPage(
sol,
await fetchTopicTags(sol),
await issueUrl(sol),
);
await Bun.write(join(DOCS, name), page);
report.push([workRef, name, "created"]); report.push([workRef, name, "created"]);
written = page;
} }
// The route drops the phase `(group)`, `(problems)` and the number prefix. for (const tag of frontmatterTopics(written)) tags.set(tag.slug, tag);
redirects.push({ from: `/${sol.num}`, to: `/${slugify(topic.name)}/${sol.slug}` }); // 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 site's one generated input besides the pages; script-owned, rewritten // Script-owned short links, one per page; imported by blume.config.ts.
// only when it differs. Folders sorted by path so a diff reads by location.
{ {
const manifest = `${JSON.stringify( const module = [
{ folders: Object.fromEntries([...folders].sort(([a], [b]) => (a < b ? -1 : 1))), redirects }, "// Generated by `bun run sync` (apps/cli/sync.ts) — do not edit.",
null, "// `/leetcode/<num>` → that problem's page; exact paths, base added by Blume.",
2, "export default [",
)}\n`; ...redirects,
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"]); ].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",
]);
} }
} }
// A folder every page left behind is dead: drop it, then the folders above it // Every carried tag no campaign topic teaches is an Appendix term, filed by
// that emptied too (`(problems)`, its topic, the phase). A folder that still // hand in apps/docs/appendix.ts: warn about the gaps and the dead weight. A
// holds anything (a hand-written index.mdx, another topic) stops the climb. // term's icon is also the icon on every problem-page badge linking to its
// card, so it must not be a topic group's (`TOPIC_ICONS`), so a term badge
// never reads as a topic's, nor another term's.
for (const tag of [...tags.values()].sort((a, b) =>
a.slug.localeCompare(b.slug),
)) {
if (!TOPIC_LANDINGS[tag.slug] && !APPENDIX[tag.slug])
console.warn(
`tag ${tag.slug}: no Appendix term; add ${JSON.stringify(tag.name)} to apps/docs/appendix.ts`,
);
}
const topicIcons = new Set(Object.values(TOPIC_ICONS));
const termIcons = new Map<string, string>(); // icon -> the term that took it first
for (const [slug, term] of Object.entries(APPENDIX)) {
if (TOPIC_LANDINGS[slug])
console.warn(
`appendix ${slug}: a campaign topic teaches it now; delete its term`,
);
if (topicIcons.has(term.icon))
console.warn(
`appendix ${slug}: icon ${JSON.stringify(term.icon)} is a topic's (TOPIC_ICONS); pick another`,
);
else if (termIcons.has(term.icon))
console.warn(
`appendix ${slug}: icon ${JSON.stringify(term.icon)} is ${termIcons.get(term.icon)}'s too; pick another`,
);
else termIcons.set(term.icon, slug);
}
// 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 — rewritten only when
// they differ.
const metas: [label: string, dir: string, meta: string][] = [...groups].map(
([dir, topic]) => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)],
);
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.
for (const start of vacated) { for (const start of vacated) {
for (let dir = start; dir !== "."; dir = dirname(dir)) { for (let dir = start; dir !== "."; dir = dirname(dir)) {
if (folders.has(dir)) break; if (groups.has(dir) || phases.has(dir)) break;
let left: string[]; let left: string[];
try { try {
left = await readdir(join(DOCS, dir)); left = await readdir(join(DOCS, dir));
} catch { } catch {
break; // already removed via a sibling's climb break; // already removed via a sibling's climb
} }
if (left.length) break; if (left.some((f) => f !== "meta.ts")) break;
if (left.length) await unlink(join(DOCS, dir, "meta.ts"));
await rmdir(join(DOCS, dir)); await rmdir(join(DOCS, dir));
report.push(["", dir, "removed (empty)"]); report.push(["", dir, "removed (empty)"]);
} }
@@ -780,4 +947,5 @@ const w0 = Math.max(...report.map((r) => r[0].length), 9);
const w1 = Math.max(...report.map((r) => r[1].length), 9); const w1 = Math.max(...report.map((r) => r[1].length), 9);
console.log(`${"work file".padEnd(w0)} ${"docs page".padEnd(w1)} status`); console.log(`${"work file".padEnd(w0)} ${"docs page".padEnd(w1)} status`);
console.log(`${"─".repeat(w0)} ${"─".repeat(w1)} ${"─".repeat(20)}`); console.log(`${"─".repeat(w0)} ${"─".repeat(w1)} ${"─".repeat(20)}`);
for (const [a, b, c] of report) console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`); for (const [a, b, c] of report)
console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);
+3 -3
View File
@@ -3,8 +3,8 @@
* Find the NeetCode explanation video for every docs page that has none. * Find the NeetCode explanation video for every docs page that has none.
* *
* A finished solution page carries frontmatter `video: <YouTube id>` — * A finished solution page carries frontmatter `video: <YouTube id>` —
* NeetCode's walkthrough of the problem — which the site's MDX `wrapper` * NeetCode's walkthrough of the problem — which the docs MDX `wrapper`
* (prdlk/cs `components/ProblemBadges.astro`) renders as the red * (`apps/docs/components/ProblemBadges.astro`) renders as the red
* "Explanation" badge beside the topic badges. `sync.ts` never writes that key * "Explanation" badge beside the topic badges. `sync.ts` never writes that key
* (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without * (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without
* one. This script lists every numbered page lacking `video:` and looks each * 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 { printTable } from "./report.ts";
import { ROOT } from "./work.ts"; import { ROOT } from "./work.ts";
const DOCS = join(ROOT, "docs"); const DOCS = join(ROOT, "apps", "docs", "content");
const WRITE = process.argv.includes("--write"); const WRITE = process.argv.includes("--write");
/** Channel handles searched in order; the first exact match wins. */ /** Channel handles searched in order; the first exact match wins. */
+1
View File
@@ -0,0 +1 @@
.blume-verify/
+232
View File
@@ -0,0 +1,232 @@
/**
* The Appendix glossary: one term per LeetCode topic tag that problem pages
* carry but no campaign topic teaches (not a key of `topic-landings.ts`).
* `pages/appendix.astro` renders each as a card — icon, category, definition,
* and the solved problems carrying the tag — anchored at `/appendix#<slug>`,
* which is where `tagTarget()` sends that tag's badge on a problem page.
*
* Hand-written: `apps/cli/sync.ts` only checks it, warning about a carried
* tag with no entry (its card renders bare), an entry whose tag a campaign
* topic now teaches (dead weight, delete it), and an icon that is a topic's
* (`TOPIC_ICONS`) or another term's — the icon is also the one on every
* badge linking here, so it must not read as anything else. Plain data, no
* Astro imports: sync imports this file under Bun.
*/
export type AppendixCategory = "Data Structures" | "Algorithms" | "Techniques";
export interface AppendixTerm {
/** LeetCode's tag name. */
name: string;
/** Lucide icon, unique across terms and topics. */
icon: string;
category: AppendixCategory;
/** One sentence: what the term means, the way a solution uses it. */
definition: string;
}
export const APPENDIX: Record<string, AppendixTerm> = {
// ── data structures ──────────────────────────────────────────────
array: {
name: "Array",
icon: "list-ordered",
category: "Data Structures",
definition: "A contiguous, index-addressed sequence: O(1) access by position, O(n) to insert or delete in the middle.",
},
"binary-tree": {
name: "Binary Tree",
icon: "git-branch",
category: "Data Structures",
definition: "A rooted tree in which every node has at most two children, left and right.",
},
"directed-acyclic-graph": {
name: "Directed Acyclic Graph",
icon: "route",
category: "Data Structures",
definition: "A directed graph with no cycles, which is exactly what lets its nodes be put in a topological order.",
},
"doubly-linked-list": {
name: "Doubly-Linked List",
icon: "link-2",
category: "Data Structures",
definition: "A linked list whose nodes point to both neighbours, so a node you already hold is unlinked in O(1).",
},
graph: {
name: "Graph Theory",
icon: "share-2",
category: "Data Structures",
definition: "Nodes joined by edges, directed or not, weighted or not, stored as an adjacency list or matrix.",
},
"k-d-tree": {
name: "K-D Tree",
icon: "axis-3d",
category: "Data Structures",
definition: "A binary tree that splits k-dimensional points on alternating axes, for nearest-neighbour and range queries.",
},
queue: {
name: "Queue",
icon: "list-end",
category: "Data Structures",
definition: "First in, first out: push at the back, pop from the front, both O(1).",
},
string: {
name: "String",
icon: "quote",
category: "Data Structures",
definition: "A sequence of characters; solutions lean on its small alphabet, its substrings, and its immutability.",
},
tree: {
name: "Tree",
icon: "tree-deciduous",
category: "Data Structures",
definition: "A connected acyclic graph, usually rooted, in which every node but the root has exactly one parent.",
},
// ── algorithms ───────────────────────────────────────────────────
"bidirectional-search": {
name: "Bidirectional Search",
icon: "move-horizontal",
category: "Algorithms",
definition: "Search from the start and the goal at once and stop where the frontiers meet, about 2·b^(d/2) states instead of b^d.",
},
"binary-lifting": {
name: "Binary Lifting",
icon: "chevrons-up",
category: "Algorithms",
definition: "Precompute every node's 2^k-th ancestor so any k-th ancestor, and so the LCA, takes O(log n) jumps.",
},
"bit-manipulation": {
name: "Bit Manipulation",
icon: "binary",
category: "Algorithms",
definition: "Treat integers as bit vectors: masks, shifts, and XOR in place of arithmetic or extra memory.",
},
"bracket-sequences": {
name: "Bracket Sequences",
icon: "parentheses",
category: "Algorithms",
definition: "Strings of opening and closing brackets; balanced means the running depth never dips below zero and ends at zero.",
},
"bucket-sort": {
name: "Bucket Sort",
icon: "container",
category: "Algorithms",
definition: "Scatter values into buckets by range or frequency, then read the buckets in order: linear when the key range is bounded.",
},
"complete-knapsack": {
name: "Complete Knapsack",
icon: "package-plus",
category: "Algorithms",
definition: "Knapsack in which every item may be taken any number of times, so the DP walks capacity upward and reuses the row.",
},
"divide-and-conquer": {
name: "Divide and Conquer",
icon: "split",
category: "Algorithms",
definition: "Split the input into independent parts, solve each recursively, and combine their answers.",
},
"floyds-cycle-finding-algorithm": {
name: "Floyd's Cycle Finding Algorithm",
icon: "repeat",
category: "Algorithms",
definition: "A slow and a fast pointer meet inside any cycle; restarting one from the head finds the cycle's entry, in O(1) space.",
},
"knapsack-problem": {
name: "Knapsack Problem",
icon: "backpack",
category: "Algorithms",
definition: "Pick items with weights and values to maximise value under a capacity; the 0/1 version is an O(n·W) DP.",
},
"longest-common-subsequence": {
name: "Longest Common Subsequence",
icon: "diff",
category: "Algorithms",
definition: "The longest sequence that appears in order, not necessarily contiguously, in two strings: the classic O(n·m) 2-D DP.",
},
"lowest-common-ancestor": {
name: "Lowest Common Ancestor",
icon: "git-merge",
category: "Algorithms",
definition: "The deepest node of a rooted tree that is an ancestor of both given nodes.",
},
memoization: {
name: "Memoization",
icon: "database",
category: "Algorithms",
definition: "Cache a pure function's result per argument so every subproblem is solved once: top-down dynamic programming.",
},
"merge-sort": {
name: "Merge Sort",
icon: "blend",
category: "Algorithms",
definition: "Sort each half recursively, then merge the two sorted halves: O(n log n), stable, O(n) extra space.",
},
quickselect: {
name: "Quickselect",
icon: "crosshair",
category: "Algorithms",
definition: "Partition around a pivot and recurse into one side only, finding the k-th smallest element in O(n) on average.",
},
quicksort: {
name: "Quicksort",
icon: "shuffle",
category: "Algorithms",
definition: "Partition around a pivot, then sort each side in place: O(n log n) on average, O(n²) at worst.",
},
recursion: {
name: "Recursion",
icon: "iteration-ccw",
category: "Algorithms",
definition: "Solve a problem by calling the same function on smaller inputs until a base case answers directly.",
},
simulation: {
name: "Simulation",
icon: "monitor-play",
category: "Algorithms",
definition: "Carry out the process the statement describes, step by step; the work is modelling the state faithfully.",
},
sorting: {
name: "Sorting",
icon: "arrow-down-wide-narrow",
category: "Algorithms",
definition: "Order the input first so a later step, such as two pointers, a greedy choice, or binary search, becomes possible.",
},
"tournament-sort": {
name: "Tournament Sort",
icon: "trophy",
category: "Algorithms",
definition: "A winner tree of pairwise comparisons: the root is the extreme element, and replaying one path yields the next in O(log n).",
},
// ── techniques ───────────────────────────────────────────────────
combinatorics: {
name: "Combinatorics",
icon: "dices",
category: "Techniques",
definition: "Count arrangements and selections (permutations, combinations, Catalan numbers), usually modulo a prime.",
},
counting: {
name: "Counting",
icon: "tally-5",
category: "Techniques",
definition: "Tally occurrences in a hash map or fixed array, then answer from the counts instead of the raw input.",
},
design: {
name: "Design",
icon: "pencil-ruler",
category: "Techniques",
definition: "Implement a class to an API spec, choosing the internals so every operation meets its time bound.",
},
geometry: {
name: "Geometry",
icon: "shapes",
category: "Techniques",
definition: "Points, lines, and areas on a plane or grid: distances, slopes, overlaps, and orientation by cross product.",
},
math: {
name: "Math",
icon: "calculator",
category: "Techniques",
definition: "An arithmetic or number-theory insight (modular arithmetic, GCD, digit manipulation, a closed form) in place of brute force.",
},
};
+152
View File
@@ -0,0 +1,152 @@
import { defineConfig } from "blume";
import type { StandardSchema } from "blume/core/standard-schema.ts";
import { filesystem } from "blume/sources";
import { APPENDIX } from "./appendix";
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). 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 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",
// Sync's `/<num>` short links, then the Appendix's former per-term pages
// (`/appendix/<slug>`, published until the glossary became one page) onto
// their cards, so old links and search results still land.
redirects: [
...redirects,
...Object.keys(APPENDIX).map((slug) => ({
from: `/appendix/${slug}`,
to: `/appendix#${slug}`,
})),
],
frontmatter: {
extend: {
topics: topicsSchema,
video: videoSchema,
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` and `/appendix` are custom pages
// (pages/*.astro): `/changelog` reads the GitHub releases itself — one
// timeline, no per-release pages — and `/appendix` is one glossary page
// built from appendix.ts, so neither enters the sidebar tree 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"
},
// The glossary is no content folder any more, so it is pinned above the
// sidebar tree, on every page and breakpoint.
featured: [
{ label: "Changelog", href: "/changelog", icon: "clock" },
{ label: "Appendix", href: "/appendix", icon: "library" },
],
},
});
+21
View File
@@ -0,0 +1,21 @@
{
"$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
@@ -0,0 +1,20 @@
/**
* 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 pages' "what links here": every problem pointing at the page.
PageFooter: "./components/BacklinksList.astro",
},
});
+95
View File
@@ -0,0 +1,95 @@
---
/**
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
* page — every problem page that points at it, as `<num> <name>` rows in
* LeetCode-number order. Pages nothing points at (problem pages, the
* overview) render nothing; a tag no topic teaches lists its problems on its
* Appendix card instead (`pages/appendix.astro`), from the same answer.
*
* 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
@@ -0,0 +1,170 @@
---
/**
* 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
@@ -0,0 +1,45 @@
---
/**
* 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>
+134
View File
@@ -0,0 +1,134 @@
---
/**
* 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 card on the
* Appendix glossary, `/appendix#<slug>` (`appendix.ts`), and is outlined.
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
* `PageFooter` backlinks list and the Appendix page read the same answer.
* Each tag badge is a pill carrying the icon its target shows (a topic's
* `meta.ts` icon in the sidebar, a term's `icon` on its card) and the tag's
* own colour (`lib/tag-colors.ts`), assigned over every tag the site has a
* page or card for — no two tags share one, and tags that share problem
* pages are spread furthest apart. A tag with neither (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.
*/
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 { APPENDIX } from "../appendix.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 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 landing or an Appendix card, 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), ...Object.keys(APPENDIX)],
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: target.kind === "appendix" ? APPENDIX[tag.slug]?.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 || 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>
)}
</div>
)
}
<slot />
+67
View File
@@ -0,0 +1,67 @@
/**
* 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
@@ -0,0 +1,91 @@
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
@@ -0,0 +1,372 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,83 @@
"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
@@ -0,0 +1,47 @@
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 }
@@ -0,0 +1,63 @@
---
title: '4. Median of Two Sorted Arrays'
description: Given two sorted arrays nums1 and nums2 of size m and n respectively, return the median of the two sorted arrays
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
- { name: "Divide and Conquer", slug: "divide-and-conquer" }
issue: "https://github.com/prdlk/leetcode/issues/66"
sidebar:
label: 'Median of Two Sorted Arrays'
badge: 'Hard'
---
### Example 1:
- Input: `nums1 = [1,3], nums2 = [2]`
- Output: `2.00000`
- Explanation: merged array = `[1,2,3]` and median is `2`.
### Example 2:
- Input: `nums1 = [1,2], nums2 = [3,4]`
- Output: `2.50000`
- Explanation: merged array = `[1,2,3,4]` and median is `(2 + 3`) / `2 = 2.5`.
### Constraints:
- `nums1.length == m`
- `nums2.length == n`
- `0 <= m <= 1000`
- `0 <= n <= 1000`
- `1 <= m + n <= 2000`
- `-10^6 <= nums1[i], nums2[i] <= 10^6`
## Solution
```py
class Solution:
def findMedianSortedArrays(self, nums1: list[int], nums2: list[int]) -> float:
if len(nums1) > len(nums2):
return self.findMedianSortedArrays(nums2, nums1)
m, n = len(nums1), len(nums2)
left, right = 0, m
while left <= right:
partA = (left + right) // 2
partB = (m + n + 1) // 2 - partA
maxLeftA = float("-inf") if partA == 0 else nums1[partA - 1]
minRightA = float("inf") if partA == m else nums1[partA]
maxLeftB = float("-inf") if partB == 0 else nums2[partB - 1]
minRightB = float("inf") if partB == n else nums2[partB]
if maxLeftA <= minRightB and maxLeftB <= minRightA:
if (m + n) % 2 == 0:
return (max(maxLeftA, maxLeftB) + min(minRightA, minRightB)) / 2
else:
return max(maxLeftA, maxLeftB)
elif maxLeftA > minRightB:
right = partA - 1
else:
left = partA + 1
```
@@ -26,14 +26,14 @@ Time Complexity: `O(log(n))`
<Carousel> <Carousel>
<CarouselImage src='/binary_search.001.png' /> <CarouselImage src='../../../public/binary_search.001.png' />
<CarouselImage src='/binary_search.002.png' /> <CarouselImage src='../../../public/binary_search.002.png' />
<CarouselImage src='/binary_search.003.png' /> <CarouselImage src='../../../public/binary_search.003.png' />
<CarouselImage src='/binary_search.004.png' /> <CarouselImage src='../../../public/binary_search.004.png' />
<CarouselImage src='/binary_search.005.png' /> <CarouselImage src='../../../public/binary_search.005.png' />
<CarouselImage src='/binary_search.006.png' /> <CarouselImage src='../../../public/binary_search.006.png' />
<CarouselImage src='/binary_search.007.png' /> <CarouselImage src='../../../public/binary_search.007.png' />
<CarouselImage src='/binary_search.008.png' /> <CarouselImage src='../../../public/binary_search.008.png' />
</Carousel> </Carousel>
```python ```python
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Binary Search", order: 5, icon: "search", display: "group" });
@@ -13,7 +13,7 @@ Data is stored in a fix-sized array, where each data value has an unique index.
<Frame caption="Hash Table Operations"> <Frame caption="Hash Table Operations">
![](/hash-table-operations.svg) ![](../../../public/hash-table-operations.svg)
</Frame> </Frame>
@@ -39,10 +39,12 @@ def get_counter(arr: list[int]) -> dict[int, int]:
return counter return counter
``` ```
::: tip[Python provides a built-in `collections.Counter` class] :::tip
Python provides a built-in [`collections.Counter`](https://docs.python.org/3/library/collections.html#collections.Counter) class that does this for us.
:::
```python ```python
from collections import Counter from collections import Counter
fq = Counter(arr) fq = Counter(arr)
``` ```
:::
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Hash Table", order: 1, icon: "hash", display: "group" });
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "1. Linear Structures", order: 1 });
@@ -0,0 +1,51 @@
---
title: '84. Largest Rectangle in Histogram'
description: Given an array of integers heights representing the histogram's bar height where the width of each bar is 1, return the area of the largest rectangle in the histogram
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Stack", slug: "stack" }
- { name: "Monotonic Stack", slug: "monotonic-stack" }
- { name: "Range Minimum/Maximum Query", slug: "range-minimum-maximum-query" }
issue: "https://github.com/prdlk/leetcode/issues/76"
sidebar:
label: 'Largest Rectangle in Histogram'
badge: 'Hard'
---
### Example 1:
- Input: `heights = [2,1,5,6,2,3]`
- Output: `10`
- Explanation: The above is a histogram where width of each bar is `1`. The largest rectangle is shown in the red area, which has an area = `10` units.
### Example 2:
- Input: `heights = [2,4]`
- Output: `4`
### Constraints:
- `1 <= heights.length <= 10^5`
- `0 <= heights[i] <= 10^4`
## Solution
```py
class Solution:
def largestRectangleArea(self, heights: list[int]) -> int:
maxArea = 0
stack = []
for i, h in enumerate(heights):
start = i
while stack and stack[-1][1] > h:
index, height = stack.pop()
width = i - index
maxArea = max(maxArea, height * width)
start = index
stack.append((start, h))
for i, h in stack:
maxArea = max(maxArea, h * (len(heights) - i))
return maxArea
```
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Monotonic Stack", order: 7, icon: "trending-up", display: "group" });
@@ -27,7 +27,7 @@ The leading `prefix[0] = 0` is the sum of zero elements. That extra slot is what
<Frame caption="Each bar is the running total up to that index. Every step keeps the previous total (blue) and adds one new element (amber)" hint=""> <Frame caption="Each bar is the running total up to that index. Every step keeps the previous total (blue) and adds one new element (amber)" hint="">
![](/prefix-sum_1.png) ![](../../../public/prefix-sum_1.png)
</Frame> </Frame>
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Prefix Sum", order: 2, icon: "sigma", display: "group" });
@@ -42,7 +42,7 @@ The window size never changes. The main task is updating the window state effici
<Frame caption="Sliding Window Fixed" hint=""> <Frame caption="Sliding Window Fixed" hint="">
![](/sliding-window-fixed-size.svg) ![](../../../public/sliding-window-fixed-size.svg)
</Frame> </Frame>
@@ -79,7 +79,7 @@ Here the window expands until invalid, then shrinks until valid again.
<Frame caption="Sliding Window Flexible" hint=""> <Frame caption="Sliding Window Flexible" hint="">
![](/sliding-window-flexible.svg) ![](../../../public/sliding-window-flexible.svg)
</Frame> </Frame>
@@ -143,7 +143,7 @@ The difficulty comes from keeping the bookkeeping correct as characters enter an
<Frame caption="Frequency-tracking windows" hint=""> <Frame caption="Frequency-tracking windows" hint="">
![](/sliding-window-frequency.svg) ![](../../../public/sliding-window-frequency.svg)
</Frame> </Frame>
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Sliding Window", order: 4, icon: "app-window", display: "group" });
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Stack", order: 6, icon: "layers", display: "group" });
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Two Pointers", order: 3, icon: "arrow-left-right", display: "group" });
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Hybrid Structures", order: 9, icon: "combine", display: "group" });
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Linked List", order: 8, icon: "link", display: "group" });
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Matrix Index Math", order: 10, icon: "grid-3x3", display: "group" });
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "2. Nodal & Grid", order: 2 });
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more