From 9fbe800fed610cc76371db80e2cd494915716077 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Tue, 25 Aug 2026 11:20:52 -0400 Subject: [PATCH] feat: migrate docs --- AGENTS.md | 56 +-- README.md | 2 +- api/.dev.vars.example | 3 - api/README.md | 90 ---- api/bun.lock | 292 ------------ api/data/schedule.json | 26 - api/migrations/0001_init.sql | 53 -- api/package.json | 21 - api/scripts/import-srs.ts | 94 ---- api/src/catalog.ts | 92 ---- api/src/charts.ts | 406 ---------------- api/src/digest.ts | 180 ------- api/src/email.tsx | 451 ------------------ api/src/gate.ts | 270 ----------- api/src/github.ts | 94 ---- api/src/index.ts | 324 ------------- api/src/links.ts | 79 --- api/src/mirror.ts | 172 ------- api/src/png.ts | 254 ---------- api/src/srs.test.ts | 59 --- api/src/srs.ts | 330 ------------- api/src/stats.ts | 107 ----- api/tsconfig.json | 18 - api/wrangler.jsonc | 41 -- blume.config.ts | 22 - docs/(algorithms)/01-frequency-map.mdx | 118 ----- docs/(algorithms)/02-two-pointers.mdx | 116 ----- docs/(algorithms)/03-prefix-sum.mdx | 119 ----- docs/(algorithms)/04-sliding-window.mdx | 117 ----- docs/(algorithms)/05-binary-search.mdx | 100 ---- docs/(algorithms)/breadth-first-search.mdx | 121 ----- docs/(algorithms)/depth-first-search.mdx | 110 ----- docs/(algorithms)/dynamic-programming.mdx | 127 ----- docs/(algorithms)/greedy.mdx | 95 ---- docs/(algorithms)/meta.ts | 6 - .../01-arrays-and-strings.mdx | 65 --- docs/(data-structures)/02-hash-tables.mdx | 101 ---- docs/(data-structures)/meta.ts | 6 - docs/index.mdx | 12 - docs/progress.mdx | 9 - docs/solutions/(array)/1-two-sum.mdx | 47 -- .../(array)/11-container-with-most-water.mdx | 48 -- .../121-best-time-to-buy-and-sell-stock.mdx | 38 -- ...rs-are-smaller-than-the-current-number.mdx | 63 --- ...value-to-get-positive-step-by-step-sum.mdx | 57 --- .../(array)/1426-counting-elements.mdx | 45 -- .../(array)/1480-running-sum-of-1d-array.mdx | 44 -- docs/solutions/(array)/15-3sum.mdx | 78 --- ...636-sort-array-by-increasing-frequency.mdx | 42 -- .../167-two-sum-ii-input-array-is-sorted.mdx | 55 --- docs/solutions/(array)/189-rotate-array.mdx | 42 -- .../2090-k-radius-subarray-averages.mdx | 50 -- .../(array)/217-contains-duplicate.mdx | 47 -- .../238-product-of-array-except-self.mdx | 53 -- docs/solutions/(array)/268-missing-number.mdx | 55 --- .../(array)/303-range-sum-query-immutable.mdx | 52 -- .../33-search-in-rotated-sorted-array.mdx | 71 --- .../(array)/347-top-k-frequent-elements.mdx | 51 -- .../(array)/42-trapping-rain-water.mdx | 36 -- docs/solutions/(array)/49-group-anagrams.mdx | 51 -- .../(array)/560-subarray-sum-equals-k.mdx | 51 -- docs/solutions/(array)/704-binary-search.mdx | 56 --- .../(array)/875-koko-eating-bananas.mdx | 62 --- .../(array)/977-squares-of-a-sorted-array.mdx | 55 --- .../1832-check-if-the-sentence-is-pangram.mdx | 41 -- .../(hash-table)/242-valid-anagram.mdx | 48 -- ...387-first-unique-character-in-a-string.mdx | 47 -- .../451-sort-characters-by-frequency.mdx | 49 -- .../(two-pointers)/125-valid-palindrome.mdx | 51 -- .../(two-pointers)/344-reverse-string.mdx | 45 -- .../(two-pointers)/392-is-subsequence.mdx | 44 -- islands/ProgressDashboard.tsx | 351 -------------- public/dependency-spine.html | 305 ------------ public/dependency-spine.svg | 244 ---------- scripts/close-solved.ts | 216 --------- scripts/close-topics.ts | 150 ------ scripts/github.ts | 93 ---- scripts/pick.ts | 96 ---- scripts/report.ts | 37 -- scripts/sync.ts | 407 ---------------- scripts/test.ts | 54 --- 81 files changed, 30 insertions(+), 8255 deletions(-) delete mode 100644 api/.dev.vars.example delete mode 100644 api/README.md delete mode 100644 api/bun.lock delete mode 100644 api/data/schedule.json delete mode 100644 api/migrations/0001_init.sql delete mode 100644 api/package.json delete mode 100755 api/scripts/import-srs.ts delete mode 100644 api/src/catalog.ts delete mode 100644 api/src/charts.ts delete mode 100644 api/src/digest.ts delete mode 100644 api/src/email.tsx delete mode 100644 api/src/gate.ts delete mode 100644 api/src/github.ts delete mode 100644 api/src/index.ts delete mode 100644 api/src/links.ts delete mode 100644 api/src/mirror.ts delete mode 100644 api/src/png.ts delete mode 100644 api/src/srs.test.ts delete mode 100644 api/src/srs.ts delete mode 100644 api/src/stats.ts delete mode 100644 api/tsconfig.json delete mode 100644 api/wrangler.jsonc delete mode 100644 blume.config.ts delete mode 100644 docs/(algorithms)/01-frequency-map.mdx delete mode 100644 docs/(algorithms)/02-two-pointers.mdx delete mode 100644 docs/(algorithms)/03-prefix-sum.mdx delete mode 100644 docs/(algorithms)/04-sliding-window.mdx delete mode 100644 docs/(algorithms)/05-binary-search.mdx delete mode 100644 docs/(algorithms)/breadth-first-search.mdx delete mode 100644 docs/(algorithms)/depth-first-search.mdx delete mode 100644 docs/(algorithms)/dynamic-programming.mdx delete mode 100644 docs/(algorithms)/greedy.mdx delete mode 100644 docs/(algorithms)/meta.ts delete mode 100644 docs/(data-structures)/01-arrays-and-strings.mdx delete mode 100644 docs/(data-structures)/02-hash-tables.mdx delete mode 100644 docs/(data-structures)/meta.ts delete mode 100644 docs/index.mdx delete mode 100644 docs/progress.mdx delete mode 100644 docs/solutions/(array)/1-two-sum.mdx delete mode 100644 docs/solutions/(array)/11-container-with-most-water.mdx delete mode 100644 docs/solutions/(array)/121-best-time-to-buy-and-sell-stock.mdx delete mode 100644 docs/solutions/(array)/1365-how-many-numbers-are-smaller-than-the-current-number.mdx delete mode 100644 docs/solutions/(array)/1413-minimum-value-to-get-positive-step-by-step-sum.mdx delete mode 100644 docs/solutions/(array)/1426-counting-elements.mdx delete mode 100644 docs/solutions/(array)/1480-running-sum-of-1d-array.mdx delete mode 100644 docs/solutions/(array)/15-3sum.mdx delete mode 100644 docs/solutions/(array)/1636-sort-array-by-increasing-frequency.mdx delete mode 100644 docs/solutions/(array)/167-two-sum-ii-input-array-is-sorted.mdx delete mode 100644 docs/solutions/(array)/189-rotate-array.mdx delete mode 100644 docs/solutions/(array)/2090-k-radius-subarray-averages.mdx delete mode 100644 docs/solutions/(array)/217-contains-duplicate.mdx delete mode 100644 docs/solutions/(array)/238-product-of-array-except-self.mdx delete mode 100644 docs/solutions/(array)/268-missing-number.mdx delete mode 100644 docs/solutions/(array)/303-range-sum-query-immutable.mdx delete mode 100644 docs/solutions/(array)/33-search-in-rotated-sorted-array.mdx delete mode 100644 docs/solutions/(array)/347-top-k-frequent-elements.mdx delete mode 100644 docs/solutions/(array)/42-trapping-rain-water.mdx delete mode 100644 docs/solutions/(array)/49-group-anagrams.mdx delete mode 100644 docs/solutions/(array)/560-subarray-sum-equals-k.mdx delete mode 100644 docs/solutions/(array)/704-binary-search.mdx delete mode 100644 docs/solutions/(array)/875-koko-eating-bananas.mdx delete mode 100644 docs/solutions/(array)/977-squares-of-a-sorted-array.mdx delete mode 100644 docs/solutions/(hash-table)/1832-check-if-the-sentence-is-pangram.mdx delete mode 100644 docs/solutions/(hash-table)/242-valid-anagram.mdx delete mode 100644 docs/solutions/(hash-table)/387-first-unique-character-in-a-string.mdx delete mode 100644 docs/solutions/(hash-table)/451-sort-characters-by-frequency.mdx delete mode 100644 docs/solutions/(two-pointers)/125-valid-palindrome.mdx delete mode 100644 docs/solutions/(two-pointers)/344-reverse-string.mdx delete mode 100644 docs/solutions/(two-pointers)/392-is-subsequence.mdx delete mode 100644 islands/ProgressDashboard.tsx delete mode 100644 public/dependency-spine.html delete mode 100644 public/dependency-spine.svg delete mode 100755 scripts/close-solved.ts delete mode 100755 scripts/close-topics.ts delete mode 100644 scripts/github.ts delete mode 100755 scripts/pick.ts delete mode 100644 scripts/report.ts delete mode 100755 scripts/sync.ts delete mode 100755 scripts/test.ts diff --git a/AGENTS.md b/AGENTS.md index dcd9c9c..b045c37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Overview -LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`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 own ~161 `problem` sub-issues (`set:core|optional|deferred`, `diff:*`); milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state. +LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) 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 own ~161 `problem` sub-issues (`set:core|optional|deferred`, `diff:*`); milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state. ## Architecture & Data Flow @@ -10,7 +10,7 @@ Three owners, one direction of truth: ```mermaid flowchart LR - work[work/ solutions] -->|bun run sync| docs[docs/solutions/*.mdx] + work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx] work -->|close-solved.yml| PI[problem issues] PI -->|close-topics.yml| TI[topic issues] GH[GitHub issues = catalog] -->|nightly reconcile| D1[(D1 = SRS state)] @@ -23,9 +23,9 @@ flowchart LR ``` - **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. -- **D1 owns SRS state; GitHub issues own the catalog; `api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). Project fields (`Target Date`, `SRS Stage`, `First Attempt`) are a best-effort mirror (`api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. -- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `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 `api/src/srs.test.ts`. +- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). Project fields (`Target Date`, `SRS Stage`, `First Attempt`) are a best-effort mirror (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. +- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind). +- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`. - **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally. ## Key Directories @@ -33,56 +33,58 @@ flowchart LR | Path | Purpose | |---|---| | `work///..{js,py}` | Solutions, e.g. `work/Easy/Array/1.two-sum.py`. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape | -| `scripts/` | Flat Bun TS: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import) | -| `docs/` | Blume site. `docs/solutions/()/-.mdx` generated by sync; `(algorithms)/`, `(data-structures)/` hand-written explainers | -| `api/` | Cloudflare Worker (own Bun workspace): `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/`, `scripts/import-srs.ts` | -| `.github/workflows/` | deploy (docs), close-solved, close-topics, sync-d1 (issue edits → `/admin/reconcile`) — Worker deploys are manual (`bun run api:deploy`) | +| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here | +| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/()/-.mdx` generated by sync | +| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/`, `scripts/import-srs.ts` | +| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved, close-topics, sync-d1 (issue edits → `/admin/reconcile`) | ## Development Commands +All from the repo root (a Bun workspace over `apps/*`): + ```sh bun run pick # scaffold a solution via leetcode-cli (fuzzy picker) bun run test # run ONE solution against LeetCode's judge (not a test suite) -bun run sync # work/ → docs/solutions/ pages -bun run dev|build # Blume docs site +bun run sync # work/ → apps/docs/content/ pages +bun run dev|build # Blume docs site (runs in apps/docs/) bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1) bun run close-topics -- --dry-run bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests -bun run api:deploy # deploy the Worker (manual by design) +bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes) curl -X POST -H "Authorization: Bearer $LINK_KEY" \ "localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing ``` ## Code Conventions & Common Patterns -- **Scripts are either entries or libraries.** Entries (`scripts/close-*.ts`) use shebang + top-level `await`. Libraries (`scripts/github.ts`, `scripts/report.ts`, everything in `api/src/`) are side-effect-free on import — nothing touches network/credentials until a factory (`github()`, `projectMirror()`) is called. +- **Scripts are either entries or libraries.** Entries (`apps/cli/close-*.ts`) use shebang + top-level `await`. Libraries (`apps/cli/github.ts`, `apps/cli/report.ts`, everything in `apps/api/src/`) are side-effect-free on import — nothing touches network/credentials until a factory (`github()`, `projectMirror()`) is called. - **Shared plumbing:** `github()` gives `repo`, `api()` (throws `METHOD path -> status body`), `list()` (paginating async generator), `closeIssue()` (comment **first**, then close — a failed PATCH still leaves a trace). `report.ts` gives `printTable`/`markdownTable`/`writeStepSummary`. - **Narrow API payloads with guards**, not inline casts: `if (!("number" in value) || typeof value.number !== "number") return;` (see `readProblemIssue` in `close-solved.ts`). Named-const casts only with a reason. - **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 — exercise logic there before touching production state. -- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `api/src/srs.ts`) to dodge DST. Never `new Date()` math directly. +- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `apps/api/src/srs.ts`) to dodge DST. Never `new Date()` math directly. - **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions. - **Known intentional duplication:** `close-solved.ts` re-implements header stripping instead of importing from `sync.ts` because `sync.ts` runs its pipeline on import. Don't "deduplicate" it. ## Important Files -- `api/src/srs.ts` — SRS domain: ladder (`new → +2 → +5 → +10 → retired`; fail resets to `+2`; first-ever log enters at `+2`), ET date math, seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here). -- `api/src/index.ts` — router + cron dispatch; `api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`. -- `api/src/digest.ts` / `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/56) LeetCode Daily Digest`. -- `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. -- `scripts/sync.ts` — work→docs contract: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`. -- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`islands/ProgressDashboard.tsx`). -- `blume.config.ts` — site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`). -- Old `.github/srs/` JSON state and the `srs-*` Actions are RETIRED — do not resurrect; `api/scripts/import-srs.ts` documents the migration. +- `apps/api/src/srs.ts` — SRS domain: ladder (`new → +2 → +5 → +10 → retired`; fail resets to `+2`; first-ever log enters at `+2`), ET date math, seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here). +- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`. +- `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/56) LeetCode Daily Digest`. +- `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: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`. +- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`). +- `apps/docs/blume.config.ts` — site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`). +- Old `.github/srs/` JSON state and the `srs-*` Actions are RETIRED — do not resurrect; `apps/api/scripts/import-srs.ts` documents the migration. ## Runtime/Tooling Preferences -- **Bun only**: run scripts with `bun scripts/.ts`, install with `bun install --frozen-lockfile`; `api/` is its own workspace with its own lockfile. Node 22 appears only in `deploy.yml` because Blume requires it. -- **No root tsconfig, no ESLint, no Prettier** — match surrounding style by hand. `api/` has a strict `tsconfig.json`; `Env` types are generated (`bunx wrangler types`), never hand-written. -- **Near-zero runtime npm dependencies** — SVG, PNG, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. The **one** exception is the digest email: `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 `api/tsconfig.json`. Do not extend this exception to any other module. +- **Bun only**: run scripts with `bun apps/cli/.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. +- **No root tsconfig, no ESLint, no Prettier** — match surrounding style by hand. `apps/api/` has a strict `tsconfig.json`; `Env` types are generated (`bunx wrangler types`), never hand-written. +- **Near-zero runtime npm dependencies** — SVG, PNG, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. The **one** exception is 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 this exception to any other module. - Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions. ## Testing & QA -- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `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. -- Docs changes: `bunx blume build --isolated` must pass with no new warnings (then `rm -rf .blume-verify`); after sync, review only *created* pages (prose transforms are heuristic). +- 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). diff --git a/README.md b/README.md index f2a33c8..be11a61 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- header + header

diff --git a/api/.dev.vars.example b/api/.dev.vars.example deleted file mode 100644 index e927b90..0000000 --- a/api/.dev.vars.example +++ /dev/null @@ -1,3 +0,0 @@ -GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw -WEBHOOK_SECRET=random_hex_matching_the_repo_webhook -LINK_KEY=random_hex_signing_one_tap_links diff --git a/api/README.md b/api/README.md deleted file mode 100644 index f030e51..0000000 --- a/api/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# srs-api - -Cloudflare Worker running the spaced-repetition system: daily digest email, -one-tap logging, `/done` webhook, Saturday review issues, live SVG charts, -and the `/api/stats` feed for the docs progress page. - -Live at `https://srs-api.prdlk.workers.dev`. - -## Direction of truth - -- **D1 owns SRS state** — stages, review dates, attempt history, gate - scores, boost flags. Nothing else is authoritative. -- **GitHub issues own the catalog** — topics, problems, `set:*`/`diff:*` - labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via - `/admin/reconcile`); it recomputes from scratch, never invents rows, and - never overwrites SRS-owned columns (`stage`, `next_review`). -- **The repo owns the schedule** — `data/schedule.json`, human-edited, - bundled at deploy. Git history is its audit log. -- The GitHub Project mirror (`Target Date`, `SRS Stage`, `First Attempt`) - is best-effort: failures log warnings, never block a D1 write. Topic rows - are never written. - -## The ladder - -`new → +2 → +5 → +10 → retired`; pass advances, fail resets to `+2`. -A problem's first-ever log enters at `+2` regardless of result. Stage names -the NEXT review's interval. All dates are ET calendar strings anchored at -noon UTC (`src/srs.ts`) — never raw `Date` math. - -Blind drills draw only from topics **already learned**: scheduled in an -earlier week (the current week's optional pool is reserved for Saturday's -gate) and with at least one core problem on the ladder — a skipped learning -day never feeds drills just because its calendar week lapsed. - -## Routes - -| Route | Auth | Purpose | -|---|---|---| -| `GET /log?p&r&d&sig` | HMAC (`LINK_KEY`) | one-tap pass/fail; idempotent per (problem, date, kind); links expire after 3 days | -| `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done pass\|fail` comments (owner only, any issue); `review`-issue close → gate scoring | -| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) | -| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` | -| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer ` | manual triggers; `digest` takes `?dry=1&force=1&date=` | - -## Crons (DST-proof) - -Each event has two UTC crons; code fires only when the computed ET hour -matches (`etHour` in `src/srs.ts`, unit-checked in `src/srs.test.ts`): - -- `0 12,13 * * *` → 8 AM ET: catalog reconcile, then the digest. -- `0 4,5 * * 6` → midnight ET Saturday: create `Review — Week N` (so the - 8 AM digest can link to it). - -## Secrets & bindings - -`wrangler secret put` — `GH_PAT` (Issues + Projects RW), `WEBHOOK_SECRET` -(matches the repo webhook), `LINK_KEY` (signs one-tap links, gates admin -routes). Bindings in `wrangler.jsonc`: `DB` (D1 `srs`), `EMAIL` -(`send_email`, restricted to the verified destination). Sender domain -`prdlk.com` is onboarded to Email Sending. - -The `sync-d1.yml` Actions workflow additionally pushes curriculum issue -edits into D1 immediately (`POST /admin/reconcile` with the -`SRS_ADMIN_KEY` repo secret); the morning cron is the backstop. - -## Local dev - -```sh -cp .dev.vars.example .dev.vars # or fill GH_PAT/WEBHOOK_SECRET/LINK_KEY -bun install -bunx wrangler d1 migrations apply srs --local -bun run dev # wrangler dev on :8787, local D1 -curl -X POST -H "Authorization: Bearer $LINK_KEY" \ - "localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing -bun test src # DST guard + date math -``` - -`?date=` on admin routes is the `SRS_TODAY` equivalent. The one-shot -migration from the retired `.github/srs/srs.json` lives at -`scripts/import-srs.ts` (`--remote` for production D1); it is re-runnable — -import-sourced attempts are wiped and re-inserted. - -## Deploy - -```sh -bun run deploy # from api/, or `bun run api:deploy` at the repo root -``` - -Manual by design — never coupled to the docs deploy. After changing -bindings, rerun `bunx wrangler types`. diff --git a/api/bun.lock b/api/bun.lock deleted file mode 100644 index 716aa74..0000000 --- a/api/bun.lock +++ /dev/null @@ -1,292 +0,0 @@ -{ - "lockfileVersion": 2, - "configVersion": 1, - "workspaces": { - "": { - "name": "srs-api", - "dependencies": { - "@react-email/components": "^1.0.12", - "@react-email/render": "^2.1.0", - "react": "^19.2.8", - "react-dom": "^19.2.8", - }, - "devDependencies": { - "@types/react": "^19.2.18", - "wrangler": "^4.125.0", - }, - }, - }, - "packages": { - "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], - - "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260820.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-5F2/t7SVnugG3rscSe9da1LoHst+GiuVGPaE9BP6j5AonlFpiYi0eoJrl3wof9zDBIIgJZ87NjRj9TKjbbYgHg=="], - - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260820.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jFnG7715+r9FXRZPpuWWe5Ayd9v/IJKDODARg56ffFJWWtte6bFNi5VY3GazBM04hEmxny6OjXQBh3j2E/wNZw=="], - - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260820.1", "", { "os": "linux", "cpu": "x64" }, "sha512-TbTYaCBht0OaOWmnVpg43hXVYtIti/Kg6lvO819H2DwbxPpK1BH0z2C+Y7ySrVJLlxZQeic5eN7/noqbP80hJg=="], - - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260820.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-FQmri1UF7hBnnpeyC5SZJCAnsSRs3+Ykn9wlO5zxmE2qIgKfbJZ+toJ6qrQyugWlxE65juT33HU6aYLtutLJHw=="], - - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260820.1", "", { "os": "win32", "cpu": "x64" }, "sha512-BPvCuMxIQfA47wtYsGbBG6Bcar57Qs7yHqU2jWkT1+sRnL/741/ZbQGP9kVRMQ5fwBMuqNFmQRzAu8gSm7ri/w=="], - - "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - - "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="], - - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="], - - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="], - - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="], - - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="], - - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="], - - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="], - - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="], - - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], - - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="], - - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - - "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], - - "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], - - "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], - - "@react-email/body": ["@react-email/body@0.3.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug=="], - - "@react-email/button": ["@react-email/button@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A=="], - - "@react-email/code-block": ["@react-email/code-block@0.2.1", "", { "dependencies": { "prismjs": "^1.30.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw=="], - - "@react-email/code-inline": ["@react-email/code-inline@0.0.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA=="], - - "@react-email/column": ["@react-email/column@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg=="], - - "@react-email/components": ["@react-email/components@1.0.12", "", { "dependencies": { "@react-email/body": "0.3.0", "@react-email/button": "0.2.1", "@react-email/code-block": "0.2.1", "@react-email/code-inline": "0.0.6", "@react-email/column": "0.0.14", "@react-email/container": "0.0.16", "@react-email/font": "0.0.10", "@react-email/head": "0.0.13", "@react-email/heading": "0.0.16", "@react-email/hr": "0.0.12", "@react-email/html": "0.0.12", "@react-email/img": "0.0.12", "@react-email/link": "0.0.13", "@react-email/markdown": "0.0.18", "@react-email/preview": "0.0.14", "@react-email/render": "2.0.6", "@react-email/row": "0.0.13", "@react-email/section": "0.0.17", "@react-email/tailwind": "2.0.7", "@react-email/text": "0.1.6" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tH18JhPDWgE+3jnYkzyB6ZrZdfNnEsFe4PwmuXmlOw4NGIysP8wPY5aXZg++pTG9qUabXg1nzX/FGHGkObH8xQ=="], - - "@react-email/container": ["@react-email/container@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ=="], - - "@react-email/font": ["@react-email/font@0.0.10", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA=="], - - "@react-email/head": ["@react-email/head@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog=="], - - "@react-email/heading": ["@react-email/heading@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw=="], - - "@react-email/hr": ["@react-email/hr@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA=="], - - "@react-email/html": ["@react-email/html@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw=="], - - "@react-email/img": ["@react-email/img@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ=="], - - "@react-email/link": ["@react-email/link@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw=="], - - "@react-email/markdown": ["@react-email/markdown@0.0.18", "", { "dependencies": { "marked": "^15.0.12" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg=="], - - "@react-email/preview": ["@react-email/preview@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw=="], - - "@react-email/render": ["@react-email/render@2.1.0", "", { "dependencies": { "entities": "^4.5.0", "html-to-text": "^9.0.5", "html5parser": "^3.0.0", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA=="], - - "@react-email/row": ["@react-email/row@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw=="], - - "@react-email/section": ["@react-email/section@0.0.17", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w=="], - - "@react-email/tailwind": ["@react-email/tailwind@2.0.7", "", { "dependencies": { "tailwindcss": "^4.1.18" }, "peerDependencies": { "@react-email/body": ">=0", "@react-email/button": ">=0", "@react-email/code-block": ">=0", "@react-email/code-inline": ">=0", "@react-email/container": ">=0", "@react-email/heading": ">=0", "@react-email/hr": ">=0", "@react-email/img": ">=0", "@react-email/link": ">=0", "@react-email/preview": ">=0", "@react-email/text": ">=0", "react": "^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@react-email/body", "@react-email/button", "@react-email/code-block", "@react-email/code-inline", "@react-email/container", "@react-email/heading", "@react-email/hr", "@react-email/img", "@react-email/link", "@react-email/preview"] }, "sha512-kGw80weVFXikcnCXbigTGXGWQ0MRCSYNCudcdkWxebkWYd0FG6/NPoN3V1p/u68/4+NxZwYPVi2fhnp0x23HdA=="], - - "@react-email/text": ["@react-email/text@0.1.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw=="], - - "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], - - "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], - - "@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="], - - "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], - - "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], - - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], - - "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], - - "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - - "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], - - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - - "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], - - "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], - - "html5parser": ["html5parser@3.0.0", "", {}, "sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A=="], - - "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], - - "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - - "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], - - "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - - "miniflare": ["miniflare@5.20260820.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260820.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-Bv1j2kcKKNwXLWuCx+j0xGt7z318mqQkJmEN6ellM9sCESbPBDTM9ofZMbKqx47jSnoGA3CiaUkAmzGVXUa/wQ=="], - - "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], - - "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - - "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], - - "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], - - "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], - - "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], - - "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], - - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - - "sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], - - "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - - "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - - "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - - "workerd": ["workerd@1.20260820.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260820.1", "@cloudflare/workerd-darwin-arm64": "1.20260820.1", "@cloudflare/workerd-linux-64": "1.20260820.1", "@cloudflare/workerd-linux-arm64": "1.20260820.1", "@cloudflare/workerd-windows-64": "1.20260820.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-/wk4rFNHH6IVMXFe6aPEZsT5YWpWtfKTUMt7+rFkyeGbXcGCy4yxODmJatbqYj0jmqHETdz0+m2mT+vNjXnF7w=="], - - "wrangler": ["wrangler@4.125.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260820.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260820.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260820.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-yFpvggu+xk1Hdm/Uxwaqa19bb7GArME4CrCS3Vov68a2TZq2MPO+wLocKbbnIC9K0oLowcdau7/ycxbbNHKCEg=="], - - "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - - "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], - - "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], - - "@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="], - } -} diff --git a/api/data/schedule.json b/api/data/schedule.json deleted file mode 100644 index 49fcd85..0000000 --- a/api/data/schedule.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "2026-08-17": 3, - "2026-08-18": 4, - "2026-08-24": 5, - "2026-08-25": 6, - "2026-08-26": 7, - "2026-08-27": 8, - "2026-08-28": 9, - "2026-08-31": 10, - "2026-09-01": 11, - "2026-09-02": 12, - "2026-09-03": 13, - "2026-09-04": 14, - "2026-09-07": 15, - "2026-09-08": 16, - "2026-09-09": 17, - "2026-09-10": 18, - "2026-09-11": 19, - "2026-09-14": 20, - "2026-09-15": 21, - "2026-09-16": 22, - "2026-09-17": 23, - "2026-09-18": 24, - "2026-09-21": 25, - "2026-09-22": 26 -} diff --git a/api/migrations/0001_init.sql b/api/migrations/0001_init.sql deleted file mode 100644 index a16615e..0000000 --- a/api/migrations/0001_init.sql +++ /dev/null @@ -1,53 +0,0 @@ --- SRS schema. D1 owns SRS state (stage, next_review, attempts, gates, --- boosts); GitHub issues own the catalog columns, reconciled nightly. -CREATE TABLE problems ( - lc_number INTEGER PRIMARY KEY, - issue INTEGER NOT NULL, - topic_issue INTEGER NOT NULL, - title TEXT NOT NULL, - difficulty TEXT NOT NULL, -- easy|medium|hard - set_label TEXT NOT NULL, -- core|optional|deferred - stage TEXT NOT NULL DEFAULT 'new', -- new|+2|+5|+10|retired - next_review TEXT, -- YYYY-MM-DD ET, NULL when retired/unsolved - defer_until TEXT -- deferred Hards: 2026-09-28+ -); - -CREATE TABLE attempts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - lc_number INTEGER NOT NULL REFERENCES problems(lc_number), - date TEXT NOT NULL, - kind TEXT NOT NULL, -- first|review|drill|gate - result TEXT NOT NULL, -- pass|fail - source TEXT NOT NULL -- email|webhook|import -); - --- One-tap email links must be idempotent (same link twice = no-op), but --- /done webhook corrections (pass then fail, same day) must stay legal — --- so uniqueness applies to email-sourced attempts only. -CREATE UNIQUE INDEX attempts_email_once - ON attempts (lc_number, date, kind) WHERE source = 'email'; -CREATE INDEX attempts_by_date ON attempts (date); - -CREATE TABLE topics ( - issue INTEGER PRIMARY KEY, - name TEXT NOT NULL, - misses INTEGER NOT NULL DEFAULT 0, - boost INTEGER NOT NULL DEFAULT 0, - milestone INTEGER -- phase; from the topic issue -); - -CREATE TABLE gates ( - week INTEGER PRIMARY KEY, -- ISO week - issue INTEGER, - problems TEXT NOT NULL, -- JSON array of lc_numbers - pass_rate REAL, - closed_on TEXT -); - -CREATE TABLE drill_pool_used (lc_number INTEGER PRIMARY KEY); - --- Digest idempotency: one email per ET date. -CREATE TABLE email_log ( - date TEXT PRIMARY KEY, -- YYYY-MM-DD ET - sent_at TEXT NOT NULL -); diff --git a/api/package.json b/api/package.json deleted file mode 100644 index ad48a25..0000000 --- a/api/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "srs-api", - "private": true, - "type": "module", - "scripts": { - "dev": "wrangler dev --test-scheduled", - "deploy": "wrangler deploy", - "types": "wrangler types", - "test": "bun test src" - }, - "devDependencies": { - "@types/react": "^19.2.18", - "wrangler": "^4.125.0" - }, - "dependencies": { - "@react-email/components": "^1.0.12", - "@react-email/render": "^2.1.0", - "react": "^19.2.8", - "react-dom": "^19.2.8" - } -} diff --git a/api/scripts/import-srs.ts b/api/scripts/import-srs.ts deleted file mode 100755 index 33e4576..0000000 --- a/api/scripts/import-srs.ts +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env bun -/** - * One-shot migration: .github/srs/srs.json → D1. - * - * Run AFTER the catalog reconcile has filled `problems` (the import only - * overlays SRS-owned fields — stage, next_review, defer_until — and inserts - * attempts/topic counters). Re-runnable without duplicates: import-sourced - * attempts are wiped and re-inserted, everything else upserts. - * - * bun api/scripts/import-srs.ts # local D1 (wrangler dev state) - * bun api/scripts/import-srs.ts --remote # production D1 - * - * Prints row counts; verify them against srs.json before deleting anything. - */ -import { $ } from "bun"; -import { join } from "node:path"; - -const ROOT = join(import.meta.dir, "..", ".."); -const API = join(ROOT, "api"); -const REMOTE = process.argv.includes("--remote"); - -interface Attempt { - date: string; - kind: string; - result: string; -} -interface Problem { - issue: number; - topic: number; - difficulty: string; - set: string; - solved_on?: string; - stage: string; - next_review?: string; - defer_until?: string; - history: Attempt[]; -} -interface State { - problems: Record; - topics: Record; - drill_pool_used: number[]; -} - -const state: State = JSON.parse( - await Bun.file(join(ROOT, ".github", "srs", "srs.json")).text(), -); - -const q = (v: string | null | undefined) => (v == null ? "NULL" : `'${v}'`); -const lines: string[] = ["DELETE FROM attempts WHERE source = 'import';"]; - -let attempts = 0; -for (const [lc, p] of Object.entries(state.problems)) { - lines.push( - `UPDATE problems SET stage = ${q(p.stage)}, next_review = ${q(p.next_review ?? null)}, ` + - `defer_until = ${q(p.defer_until ?? null)} WHERE lc_number = ${Number(lc)};`, - ); - for (const a of p.history) { - lines.push( - `INSERT INTO attempts (lc_number, date, kind, result, source) ` + - `VALUES (${Number(lc)}, ${q(a.date)}, ${q(a.kind)}, ${q(a.result)}, 'import');`, - ); - attempts++; - } -} -for (const [topic, t] of Object.entries(state.topics)) { - lines.push( - `UPDATE topics SET misses = ${t.misses}, boost = ${t.boost ? 1 : 0} WHERE issue = ${Number(topic)};`, - ); -} -for (const lc of state.drill_pool_used) { - lines.push(`INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (${lc});`); -} - -const sqlPath = join(API, "migrations", ".import.sql"); -await Bun.write(sqlPath, lines.join("\n") + "\n"); -const flag = REMOTE ? "--remote" : "--local"; -await $`bunx wrangler d1 execute srs ${flag} --file ${sqlPath}`.cwd(API); -await $`rm ${sqlPath}`; - -const counts = - await $`bunx wrangler d1 execute srs ${flag} --json --command ${"SELECT (SELECT COUNT(*) FROM problems) AS problems, (SELECT COUNT(*) FROM problems WHERE stage != 'new') AS laddered, (SELECT COUNT(*) FROM problems WHERE defer_until IS NOT NULL) AS deferred, (SELECT COUNT(*) FROM attempts WHERE source='import') AS imported_attempts, (SELECT COUNT(*) FROM topics) AS topics, (SELECT COUNT(*) FROM drill_pool_used) AS drills_used"}` - .cwd(API) - .json(); - -const expected = { - json_problems: Object.keys(state.problems).length, - json_attempts: attempts, - json_laddered: Object.values(state.problems).filter((p) => p.stage !== "new").length, - json_deferred: Object.values(state.problems).filter((p) => p.defer_until).length, - json_topics: Object.keys(state.topics).length, - json_drills_used: state.drill_pool_used.length, -}; -console.log("expected from srs.json:", JSON.stringify(expected)); -console.log("in D1:", JSON.stringify(counts[0]?.results?.[0] ?? counts)); diff --git a/api/src/catalog.ts b/api/src/catalog.ts deleted file mode 100644 index 0f1afc4..0000000 --- a/api/src/catalog.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Nightly GitHub → D1 catalog reconcile. GitHub issues own the catalog - * (topics, problems, set/diff labels, milestones); this recomputes desired - * rows from scratch on every run — re-runs are no-ops. SRS-owned columns - * (stage, next_review) are NEVER overwritten; defer_until is set once, on - * first sight of a deferred problem. The Worker never invents catalog rows. - */ -import { addDays } from "./srs.ts"; -import type { GitHub } from "./github.ts"; - -const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/; - -/** Deferred Hards enter the queue from this date, two per day. */ -const DEFER_FROM = "2026-09-28"; - -export interface ReconcileReport { - topics: number; - problems: number; -} - -export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise { - interface TopicIssue { - number: number; - title: string; - milestone: number | null; - } - const topics: TopicIssue[] = []; - for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) { - if (!raw || typeof raw !== "object") continue; - if ("pull_request" in raw) continue; - if (!("number" in raw) || typeof raw.number !== "number") continue; - if (!("title" in raw) || typeof raw.title !== "string") continue; - let milestone: number | null = null; - if ( - "milestone" in raw && - raw.milestone && - typeof raw.milestone === "object" && - "number" in raw.milestone && - typeof raw.milestone.number === "number" - ) { - milestone = raw.milestone.number; - } - topics.push({ number: raw.number, title: raw.title, milestone }); - } - - const statements: D1PreparedStatement[] = []; - for (const t of topics) { - statements.push( - db - .prepare( - `INSERT INTO topics (issue, name, milestone) VALUES (?1, ?2, ?3) - ON CONFLICT(issue) DO UPDATE SET name = ?2, milestone = ?3`, - ) - .bind(t.number, t.title.replace(/^Topic \d+ — /, ""), t.milestone), - ); - } - - let problems = 0; - let deferredSeen = 0; - for (const t of topics) { - for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) { - if (!raw || typeof raw !== "object") continue; - if (!("number" in raw) || typeof raw.number !== "number") continue; - if (!("title" in raw) || typeof raw.title !== "string") continue; - const m = raw.title.match(TITLE_RE); - if (!m) continue; // non-curriculum sub-issue - const lc = Number(m[1]); - const set = m[4]!; - // Two deferred Hards per day from DEFER_FROM, in catalog walk order — - // applied only when the row is first created (SRS owns it afterwards). - const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null; - if (set === "deferred") deferredSeen++; - statements.push( - db - .prepare( - `INSERT INTO problems (lc_number, issue, topic_issue, title, difficulty, set_label, defer_until, next_review) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) - ON CONFLICT(lc_number) DO UPDATE SET - issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`, - ) - .bind(lc, raw.number, t.number, m[2]!, m[3]!.toLowerCase(), set, defer), - ); - problems++; - } - } - - // D1 batches are transactional; chunk to stay under statement limits. - for (let i = 0; i < statements.length; i += 50) { - await db.batch(statements.slice(i, i + 50)); - } - return { topics: topics.length, problems }; -} diff --git a/api/src/charts.ts b/api/src/charts.ts deleted file mode 100644 index 6411fd9..0000000 --- a/api/src/charts.ts +++ /dev/null @@ -1,406 +0,0 @@ -/** - * Hand-rolled SVG chart generation for the SRS Worker. - * - * Feeds five endpoints — /chart/progress.svg, /chart/ladder.svg, - * /chart/heatmap.svg, /chart/heatmap.png, /badge/gate.svg — each served with - * `Cache-Control: public, max-age=300`. The heatmap has a raster twin because - * every major email client blocks SVG, so the digest cannot embed the vector. - * - * The SVG is string-built: no chart library, no dependencies, and the one - * import is the hand-rolled PNG encoder next door. The only dynamic values - * entering the markup are numbers, percentages, and dates the Worker itself - * computes, plus the fixed phase/stage labels — so nothing here needs XML - * escaping. Styling is shadcn dark zinc to match the README's shieldcn - * badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode - * green ramp. Every function renders on a zero-row DB. - */ -import { Canvas, textWidth } from "./png.ts"; - -// D1Database comes from the generated worker-configuration.d.ts runtime types. - -const FONT = "Verdana,DejaVu Sans,sans-serif"; - -// shadcn dark-zinc palette, matching the README's shieldcn badges. -const BG = "#09090b"; // zinc-950 card -const FG = "#fafafa"; // zinc-50 text -const MUTED = "#a1a1aa"; // zinc-400 secondary text -const LINE = "#27272a"; // zinc-800 structure / empty -const GREEN = "#16a34a"; // green-600 (core / pass) -const BLUE = "#2563eb"; // blue-600 (optional) -const RED = "#f87171"; // red-400 (fail, on dark) - -// Green ramp shared by the heatmap and the ladder (GitHub dark-mode -// contribution hues; level 0 is the empty zinc cell). -const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"]; - -// Labels are stored SVG-ready: phase II's "&" is pre-escaped since these -// strings go straight into markup and nothing else here needs escaping. -const PHASES = [ - { milestone: 1, name: "I — Linear" }, - { milestone: 2, name: "II — Nodal & Grid" }, - { milestone: 3, name: "III — Hierarchical" }, - { milestone: 4, name: "IV — Relational" }, - { milestone: 5, name: "V — Decision Space" }, -]; - -const STAGES = ["new", "+2", "+5", "+10", "retired"]; - -// ── svg helpers ────────────────────────────────────────────────── - -/** Opening tag plus the rounded dark card every chart starts with. */ -function svgOpen(width: number, height: number): string { - return ( - `` + - `` - ); -} - -/** A element; charts place dozens of these with the same defaults. */ -function text( - x: number, - y: number, - content: string, - attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {}, -): string { - const { size = 12, fill = FG, anchor = "start", weight } = attrs; - const bold = weight ? ` font-weight="${weight}"` : ""; - return `${content}`; -} - -// ── progress: stacked bar per phase ────────────────────────────── - -interface ProgressRow { - milestone: number; - set_label: string; - done: number; - total: number; -} - -export async function progressChart(db: D1Database): Promise { - const { results } = await db - .prepare( - `SELECT t.milestone AS milestone, p.set_label AS set_label, - SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done, - COUNT(*) AS total - FROM problems p JOIN topics t ON p.topic_issue = t.issue - WHERE t.milestone IS NOT NULL - GROUP BY t.milestone, p.set_label`, - ) - .all(); - - // Per phase: core done / optional done / remaining (all-set total − all done). - const phases = PHASES.map((phase) => { - const rows = results.filter((r) => r.milestone === phase.milestone); - const total = rows.reduce((n, r) => n + r.total, 0); - const done = rows.reduce((n, r) => n + r.done, 0); - return { - name: phase.name, - coreDone: rows.find((r) => r.set_label === "core")?.done ?? 0, - optionalDone: rows.find((r) => r.set_label === "optional")?.done ?? 0, - remaining: total - done, - done, - total, - }; - }); - - const width = 640; - const height = 300; - const barX = 168; - const barMaxW = 400; - const barH = 22; - const rowStep = 48; - const scale = Math.max(1, ...phases.map((p) => p.total)); - - const parts = [svgOpen(width, height)]; - - // Legend on top. - const legend: [string, string][] = [ - [GREEN, "core done"], - [BLUE, "optional done"], - [LINE, "remaining"], - ]; - let lx = barX; - for (const [color, label] of legend) { - parts.push(``); - parts.push(text(lx + 17, 22, label, { size: 11, fill: MUTED })); - lx += 17 + label.length * 7 + 24; - } - - phases.forEach((p, i) => { - const y = 52 + i * rowStep; - const midY = y + barH / 2 + 4; - parts.push(text(barX - 12, midY, p.name, { anchor: "end" })); - - let x = barX; - const segments: [number, string][] = [ - [p.coreDone, GREEN], - [p.optionalDone, BLUE], - [p.remaining, LINE], - ]; - for (const [count, color] of segments) { - const w = (count / scale) * barMaxW; - if (w > 0) { - parts.push(``); - // Count inside the segment when it fits; tiny slivers stay unlabeled. - if (w >= 20) { - const labelFill = color === LINE ? MUTED : FG; - parts.push(text(x + w / 2, midY, String(count), { size: 11, fill: labelFill, anchor: "middle" })); - } - x += w; - } - } - parts.push(text(x + 8, midY, `${p.done}/${p.total}`, { size: 11, fill: MUTED })); - }); - - parts.push(""); - return parts.join(""); -} - -// ── ladder: bar per stage ──────────────────────────────────────── - -export async function ladderChart(db: D1Database): Promise { - const { results } = await db - .prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`) - .all<{ stage: string; count: number }>(); - - const counts = STAGES.map((s) => results.find((r) => r.stage === s)?.count ?? 0); - - const width = 640; - const height = 220; - const plotTop = 26; - const plotBottom = height - 32; - const plotH = plotBottom - plotTop; - const slotW = (width - 80) / STAGES.length; - const barW = 64; - const scale = Math.max(1, ...counts); - - const parts = [svgOpen(width, height)]; - - counts.forEach((count, i) => { - const cx = 40 + slotW * i + slotW / 2; - const barH = (count / scale) * plotH; - const y = plotBottom - barH; - if (barH > 0) { - parts.push( - ``, - ); - } - parts.push(text(cx, Math.max(y - 6, 16), String(count), { size: 12, anchor: "middle", weight: "bold" })); - parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: MUTED, anchor: "middle" })); - }); - - parts.push(``); - parts.push(""); - return parts.join(""); -} - -// ── heatmap: attempts per campaign day ─────────────────────────── - -const DAY_MS = 86_400_000; - -// Layout in CSS pixels. heatmapPng multiplies every one of these by its device -// scale, so the vector and raster pictures cannot drift apart. -const HEAT_W = 560; -const HEAT_H = 160; -const HEAT_GRID_X = 34; -const HEAT_GRID_Y = 24; -const HEAT_CELL = 16; -const HEAT_STEP = 19; // cell + 3px gap - -// Grid row → left-hand label; both renderers print only these three. -const WEEKDAYS: [number, string][] = [ - [0, "Mon"], - [2, "Wed"], - [4, "Fri"], -]; - -/** One grid square: column, Mon-based row, and index into GREENS. */ -interface HeatmapCell { - col: number; - row: number; - level: number; -} - -/** - * The heatmap's whole data model — one cell per campaign day, plus the week - * count that positions the legend — shared by both renderers. - * - * Every per-day date derives from one UTC-midnight timestamp, so local-timezone - * drift never shifts a cell. The campaign starts on a Monday, so day i sits at - * column i/7; the row comes from the real weekday, so an off-Monday start still - * lands correctly. - */ -async function heatmapCells( - db: D1Database, - start: string, - days: number, -): Promise<{ cells: HeatmapCell[]; weeks: number }> { - const [sy, sm, sd] = start.split("-").map(Number); - const base = Date.UTC(sy!, sm! - 1, sd!); - const end = new Date(base + (days - 1) * DAY_MS).toISOString().slice(0, 10); - - const { results } = await db - .prepare(`SELECT date, COUNT(*) AS attempts FROM attempts WHERE date >= ?1 AND date <= ?2 GROUP BY date`) - .bind(start, end) - .all<{ date: string; attempts: number }>(); - - const byDate = new Map(results.map((r) => [r.date, r.attempts])); - - const cells: HeatmapCell[] = []; - for (let i = 0; i < days; i++) { - const day = new Date(base + i * DAY_MS); - const attempts = byDate.get(day.toISOString().slice(0, 10)) ?? 0; - cells.push({ - col: Math.floor(i / 7), - row: (day.getUTCDay() + 6) % 7, // Mon = 0 - level: Math.min(attempts, 4), - }); - } - - return { cells, weeks: Math.ceil(days / 7) }; -} - -export async function heatmapChart(db: D1Database, start: string, days: number): Promise { - const { cells, weeks } = await heatmapCells(db, start, days); - - const parts = [svgOpen(HEAT_W, HEAT_H)]; - - // Week numbers across the top, Mon/Wed/Fri down the left. - for (let w = 0; w < weeks; w++) { - parts.push( - text(HEAT_GRID_X + w * HEAT_STEP + HEAT_CELL / 2, HEAT_GRID_Y - 7, `W${w + 1}`, { - size: 10, - fill: MUTED, - anchor: "middle", - }), - ); - } - for (const [row, label] of WEEKDAYS) { - parts.push( - text(HEAT_GRID_X - 6, HEAT_GRID_Y + row * HEAT_STEP + HEAT_CELL - 4, label, { - size: 10, - fill: MUTED, - anchor: "end", - }), - ); - } - - for (const { col, row, level } of cells) { - parts.push( - ``, - ); - } - - // Less → More ramp fills the space right of the grid. - const legendX = HEAT_GRID_X + weeks * HEAT_STEP + 40; - const legendY = HEAT_GRID_Y + 3 * HEAT_STEP; - parts.push(text(legendX - 6, legendY + HEAT_CELL - 4, "Less", { size: 10, fill: MUTED, anchor: "end" })); - GREENS.forEach((color, i) => { - parts.push( - ``, - ); - }); - parts.push(text(legendX + GREENS.length * HEAT_STEP + 3, legendY + HEAT_CELL - 4, "More", { size: 10, fill: MUTED })); - - parts.push(""); - return parts.join(""); -} - -// The PNG carries no alpha channel, so whatever the rounded card does not cover -// has to be painted with the colour sitting behind the image: the digest -// email's GitHub-dark card. -const EMAIL_CARD = "#0d1117"; - -// Device-pixel multiplier. The email hands the image the full 552px card -// width, so 3x lands a little over 2x density on a retina screen; the labels -// stay 10 CSS px, the size of the SVG's. Flat colour compresses to a few KB -// either way, so the extra resolution is close to free. -const HEAT_SCALE = 3; - -/** - * The same picture as heatmapChart, rastered for email. - * - * Two deliberate departures from the SVG. The canvas is trimmed to the width - * the content actually occupies instead of HEAT_W: the README's SVG sits in a - * narrow table cell and scales up, whereas the email gives the image the full - * 552px card, and the SVG's slack right margin would otherwise shrink the - * grid to nothing. And the bitmap font hangs off a top-left origin rather than - * a baseline, so each SVG baseline becomes "baseline minus one cap height". - */ -export async function heatmapPng(db: D1Database, start: string, days: number): Promise { - const { cells, weeks } = await heatmapCells(db, start, days); - - const s = HEAT_SCALE; - const gridX = HEAT_GRID_X * s; - const gridY = HEAT_GRID_Y * s; - const cell = HEAT_CELL * s; - const step = HEAT_STEP * s; - const capH = 7 * s; - - const legendX = gridX + weeks * step + 40 * s; - const legendY = gridY + 3 * step; - const moreX = legendX + GREENS.length * step + 3 * s; - const width = moreX + textWidth("More", s) + gridX; - const height = HEAT_H * s; - - const canvas = new Canvas(width, height, EMAIL_CARD); - canvas.rect(0, 0, width, height, BG, 6 * s); - - for (let w = 0; w < weeks; w++) { - const label = `W${w + 1}`; - const middle = gridX + w * step + cell / 2; - canvas.text(middle - textWidth(label, s) / 2, gridY - 7 * s - capH, label, MUTED, s); - } - for (const [row, label] of WEEKDAYS) { - canvas.text( - gridX - 6 * s - textWidth(label, s), - gridY + row * step + cell - 4 * s - capH, - label, - MUTED, - s, - ); - } - - for (const { col, row, level } of cells) { - canvas.rect(gridX + col * step, gridY + row * step, cell, cell, GREENS[level]!, 2 * s); - } - - const legendBase = legendY + cell - 4 * s - capH; - canvas.text(legendX - 6 * s - textWidth("Less", s), legendBase, "Less", MUTED, s); - GREENS.forEach((color, i) => { - canvas.rect(legendX + i * step, legendY, cell, cell, color, 2 * s); - }); - canvas.text(moreX, legendBase, "More", MUTED, s); - - return await canvas.encode(); -} - -// ── gate badge: shieldcn-style dark pill ───────────────────────── - -export async function gateBadge(db: D1Database): Promise { - const row = await db - .prepare(`SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1`) - .first<{ pass_rate: number }>(); - - const label = "gate"; - const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet"; - const valueFill = row ? (row.pass_rate >= 0.7 ? "#4ade80" : RED) : MUTED; - - // shieldcn geometry: height 32, rx 6, one flat zinc-900 pill. Verdana 13px - // ≈ 7.5px per char; 12px outer padding, 8px between label and value. - const labelW = Math.round(label.length * 7.5); - const valueW = Math.round(value.length * 7.5); - const total = 12 + labelW + 8 + valueW + 12; - - return ( - `` + - `` + - `` + - `${label}` + - `${value}` + - `` + - `` - ); -} diff --git a/api/src/digest.ts b/api/src/digest.ts deleted file mode 100644 index fd8b89d..0000000 --- a/api/src/digest.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * The daily digest email — built from D1 + the bundled schedule, sent at - * 8 AM ET via the send_email binding. This module owns data only: it turns - * D1 rows into a `DigestData` and hands it to email.tsx, which renders both - * the HTML and the plain-text alternative from that single tree (React Email - * `render`), so the two can never drift. - * - * Retrieval rules: review and drill rows carry number + difficulty only — - * never the topic, never a solution link. `DigestRow` has no field for - * either, so the rule holds by construction. The learning day's core list is - * the only labeled section. Reviews + drills ≤ 6, reviews first, overflow - * simply stays due (oldest tomorrow). Sunday is a two-line rest note. - * - * Idempotency: email_log keys sends by ET date — a same-day re-send is a - * no-op unless forced; the body itself is deterministic (drill picks are - * seeded by the date). - */ -import { - CAMPAIGN_DAYS, - type ProblemRow, - SCHEDULE, - addDays, - campaignDay, - campaignWeek, - dueReviews, - isoWeek, - pickDrills, - prettyDate, - streak, - weekdayOf, -} from "./srs.ts"; -import { type DigestData, type DigestRow, renderDigest } from "./email.tsx"; -import { signLink } from "./links.ts"; - -const DAILY_CAP = 6; - -export interface Digest { - subject: string; - html: string; - text: string; -} - -/** A problem row plus its signed one-tap pass/fail URLs. */ -async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): Promise { - const [pass, fail] = await Promise.all([ - signLink(env.LINK_KEY, p.lc_number, "pass", date), - signLink(env.LINK_KEY, p.lc_number, "fail", date), - ]); - const base = `${env.PUBLIC_URL}/log?p=${p.lc_number}&d=${date}&r=`; - return { - lc: p.lc_number, - difficulty: p.difficulty, - ...(staged ? { stage: p.stage } : {}), - passUrl: `${base}pass&sig=${pass}`, - failUrl: `${base}fail&sig=${fail}`, - }; -} - -/** Everything the email needs, read straight out of D1 and the schedule. */ -export async function collectDigest(env: Env, date: string): Promise { - const db = env.DB; - const rest = weekdayOf(date) === 0; - - // Reviews take the cap first; drills fill whatever is left. - const due = rest ? [] : await dueReviews(db, date); - const capped = due.slice(0, DAILY_CAP); - const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - capped.length); - - const data: DigestData = { - day: prettyDate(date), - progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`, - streak: await streak(db, date), - rest, - reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))), - carried: due.length - capped.length, - drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))), - yesterday: { total: 0, failed: [] }, - heatmapUrl: `${env.PUBLIC_URL}/chart/heatmap.png`, - progressUrl: `${env.DOCS_URL}/progress`, - }; - - // New topic — the only section allowed to name problems and link them. - const topicIssue = rest ? undefined : SCHEDULE[date]; - if (topicIssue !== undefined) { - const topic = await db - .prepare("SELECT name FROM topics WHERE issue = ?") - .bind(topicIssue) - .first<{ name: string }>(); - const { results: core } = await db - .prepare( - "SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number", - ) - .bind(topicIssue) - .all(); - data.topic = { - name: topic?.name ?? `#${topicIssue}`, - core: core.map((p) => ({ - lc: p.lc_number, - url: `https://github.com/${env.REPO}/issues/${p.issue}`, - solved: p.stage !== "new", - })), - }; - } - - // Saturday: the review issue already exists (created at midnight ET). - if (weekdayOf(date) === 6) { - const gate = await db - .prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL") - .bind(isoWeek(date)) - .first<{ issue: number }>(); - if (gate) { - data.gate = { - week: campaignWeek(date), - url: `https://github.com/${env.REPO}/issues/${gate.issue}`, - }; - } - } - - // Footer: yesterday's log summarised (failures named — they are the - // actionable part) and the gate rate to date. - const { results: logged } = await db - .prepare("SELECT lc_number, result FROM attempts WHERE date = ? ORDER BY id") - .bind(addDays(date, -1)) - .all<{ lc_number: number; result: string }>(); - data.yesterday = { - total: logged.length, - failed: logged.filter((a) => a.result !== "pass").map((a) => a.lc_number), - }; - const lastGate = await db - .prepare("SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1") - .first<{ pass_rate: number }>(); - if (lastGate) data.gateRate = lastGate.pass_rate; - - return data; -} - -export async function buildDigest(env: Env, date: string): Promise { - const data = await collectDigest(env, date); - const { html, text } = await renderDigest(data); - return { - subject: `(Day ${campaignDay(date)}/${CAMPAIGN_DAYS}) LeetCode Daily Digest`, - html, - text, - }; -} - -export interface SendReport { - sent: boolean; - reason: string; - digest: Digest; -} - -/** Send today's digest exactly once per ET date (unless forced). */ -export async function sendDigest( - env: Env, - date: string, - opts: { force?: boolean; dry?: boolean } = {}, -): Promise { - const digest = await buildDigest(env, date); - if (opts.dry) return { sent: false, reason: "dry run", digest }; - - const already = await env.DB.prepare("SELECT sent_at FROM email_log WHERE date = ?") - .bind(date) - .first(); - if (already && !opts.force) return { sent: false, reason: "already sent today", digest }; - - await env.EMAIL.send({ - to: env.TO_EMAIL, - from: { email: env.FROM_EMAIL, name: "SRS" }, - subject: digest.subject, - html: digest.html, - text: digest.text, - }); - await env.DB.prepare( - "INSERT INTO email_log (date, sent_at) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET sent_at = excluded.sent_at", - ) - .bind(date, new Date().toISOString()) - .run(); - return { sent: true, reason: already ? "forced re-send" : "sent", digest }; -} diff --git a/api/src/email.tsx b/api/src/email.tsx deleted file mode 100644 index f023638..0000000 --- a/api/src/email.tsx +++ /dev/null @@ -1,451 +0,0 @@ -/** - * Presentation layer for the daily digest — React Email components rendered - * to HTML (and to the plain-text alternative) inside the Worker. - * - * This file owns *only* layout and wording. Every number, URL and signature - * is computed in digest.ts and handed over as `DigestData`, so the retrieval - * rules (review/drill rows carry number + difficulty only — never the topic, - * never a solution link) are enforced by what the data model can express: - * `DigestRow` has no title and no issue field. - * - * Dark by design. The theme is hard-coded rather than left to the client's - * dark-mode heuristics: `color-scheme: dark` tells Apple Mail and Outlook not - * to re-invert it, and the canvas colour is painted by a full-width

- * table because Gmail drops styles on . - * - * Other email constraints that shape the markup: inline styles only (clients - * strip - - -
-

prdlk / leetcode · dependency spine

-

How the 24 topics build on each other

- - - LeetCode topic dependency spine - Dependency graph of 24 LeetCode study topics across five phases, showing which topics build on which, with Tree DFS and 1-D dynamic programming as the two central hubs. - - - - - - - - - - - - PHASE I — LINEAR STRUCTURES - - - - PHASE III — HIERARCHICAL - - - - PHASE II — NODAL & GRID - - - - PHASES IV–V — RELATIONAL & DECISION-SPACE - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 05 - Binary Search - - - - - - 03 - Two Pointers - - - - - - 04 - Sliding Window - - - - - - 06 - Stack - - - - - - 07 - Monotonic Stack - - - - - - 01 - Hash Lookup - - - - - - 02 - Prefix Sum - - - - - - 11 - Tree DFS - - - - - - 13 - Binary Search Tree - - - - - - 14 - Heap - priority queue - - - - - - 15 - Trie - - - - - - 12 - Tree BFS - - - - - - 08 - Linked List - - - - - - 09 - Hybrid Structures - - - - - - 10 - Matrix Index Math - - - - - - 16 - Graph DFS - - - - - - 18 - Topological Sort - - - - - - 19 - Union-Find - - - - - - 20 - Backtracking - - - - - - 21 - 1-D DP - dynamic programming - - - - - - 22 - Multi-D / Grid DP - - - - - - 17 - Graph BFS - - - - - - 23 - Greedy - - - - - - 24 - Intervals - - - - LEGEND - - - ENTRY — NO PREREQS - - - TOPIC - - - FOCAL HUB - - - BUILDS ON - - - PHASE ZONE - -
- - diff --git a/public/dependency-spine.svg b/public/dependency-spine.svg deleted file mode 100644 index 1eb30df..0000000 --- a/public/dependency-spine.svg +++ /dev/null @@ -1,244 +0,0 @@ - - - LeetCode topic dependency spine - Dependency graph of 24 LeetCode study topics across five phases, showing which topics build on which, with Tree DFS and 1-D dynamic programming as the two central hubs. - - - - - - - - - - - - - PHASE I — LINEAR STRUCTURES - - - - PHASE III — HIERARCHICAL - - - - PHASE II — NODAL & GRID - - - - PHASES IV–V — RELATIONAL & DECISION-SPACE - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 05 - Binary Search - - - - - - 03 - Two Pointers - - - - - - 04 - Sliding Window - - - - - - 06 - Stack - - - - - - 07 - Monotonic Stack - - - - - - 01 - Hash Lookup - - - - - - 02 - Prefix Sum - - - - - - 11 - Tree DFS - - - - - - 13 - Binary Search Tree - - - - - - 14 - Heap - priority queue - - - - - - 15 - Trie - - - - - - 12 - Tree BFS - - - - - - 08 - Linked List - - - - - - 09 - Hybrid Structures - - - - - - 10 - Matrix Index Math - - - - - - 16 - Graph DFS - - - - - - 18 - Topological Sort - - - - - - 19 - Union-Find - - - - - - 20 - Backtracking - - - - - - 21 - 1-D DP - dynamic programming - - - - - - 22 - Multi-D / Grid DP - - - - - - 17 - Graph BFS - - - - - - 23 - Greedy - - - - - - 24 - Intervals - - - - LEGEND - - - ENTRY — NO PREREQS - - - TOPIC - - - FOCAL HUB - - - BUILDS ON - - - PHASE ZONE - diff --git a/scripts/close-solved.ts b/scripts/close-solved.ts deleted file mode 100755 index 4431982..0000000 --- a/scripts/close-solved.ts +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env bun -/** - * Close the GitHub issue for every LeetCode problem actually solved under work/. - * - * Reconciles state instead of reacting to a push diff: any `problem`-labelled - * issue whose LC number has an *implemented* solution file in work/ gets - * closed. Only open issues are touched, so re-runs are no-ops and backfilling - * needs no special casing. Removing a solution never reopens an issue. - * - * `bun run pick` scaffolds a statement header plus an empty function body, so - * file existence alone means nothing — a stub must not close its issue. See - * isImplemented(). - * - * bun scripts/close-solved.ts # close matches - * bun scripts/close-solved.ts --dry-run # report only, touch nothing - * - * Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`. - */ -import { basename, join } from "node:path"; - -import { github } from "./github.ts"; -import { markdownTable, printTable, writeStepSummary } from "./report.ts"; - -const ROOT = join(import.meta.dir, ".."); -const WORK = join(ROOT, "work"); - -const DRY = - process.argv.includes("--dry-run") || - process.env.DRY_RUN === "1" || - process.env.DRY_RUN === "true"; - -// ── is the file a real solution or just a scaffolded stub? ──────── - -/** Placeholder bodies that leetcode-cli / a human leaves behind. */ -const PLACEHOLDERS: Record = { pass: true, "...": true, TODO: true }; - -/** - * Decide whether a work/ file contains an implementation. - * - * The header comment is dropped the same way sync.ts splitSource() does it - * (duplicated rather than imported, because sync.ts runs its whole pipeline on - * import). Comments must go before any brace analysis: the scaffold's JSDoc - * carries `@param {number[]}`, whose braces would otherwise read as a body. - * - * A brace-language file counts as implemented when at least one *innermost* - * brace pair holds real content. That distinguishes a bare stub - * (`function(nums) {}`) and a class-shaped design stub (every method body - * empty) from any genuine solution, whose innermost block always has code. - */ -function isImplemented(src: string, lang: "js" | "py"): boolean { - if (lang === "py") { - const open = src.indexOf('"""'); - const close = src.indexOf('"""', open + 3); - const code = open === -1 || close === -1 ? src : src.slice(close + 3); - for (const raw of code.split("\n")) { - const line = raw.replace(/#.*$/, "").trim(); - if (!line || PLACEHOLDERS[line]) continue; - if (/^(?:@|def\s|class\s)/.test(line)) continue; - return true; // a statement inside some def body - } - return false; - } - - const headerEnd = src.indexOf("*/"); - const body = headerEnd === -1 ? src : src.slice(headerEnd + 2); - const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " "); - - let innermost = -1; - for (let i = 0; i < code.length; i++) { - if (code[i] === "{") { - innermost = i; - } else if (code[i] === "}" && innermost !== -1) { - const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, ""); - if (inner && !PLACEHOLDERS[inner]) return true; - innermost = -1; // measured; the enclosing pair is not innermost - } - } - return false; -} - -// ── repo + auth ────────────────────────────────────────────────── - -const gh = await github(); - -// ── work/ inventory ────────────────────────────────────────────── - -interface WorkEntry { - files: string[]; - implemented: boolean; -} - -/** LC number -> solution files (a problem may have both js and py). */ -const work = new Map(); -for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) { - const m = basename(rel).match(/^(\d+)\.(.+)\.(?:js|py)$/); - if (!m) { - console.warn(`skip (unrecognized name): work/${rel}`); - continue; - } - const num = Number(m[1]); - const src = await Bun.file(join(WORK, rel)).text(); - const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js"); - const entry = work.get(num); - if (entry) { - entry.files.push(`work/${rel}`); - entry.implemented ||= implemented; - } else { - work.set(num, { files: [`work/${rel}`], implemented }); - } -} - -// ── open problem issues ────────────────────────────────────────── - -interface ProblemIssue { - number: number; - set: string; -} - -/** - * Narrow one element of the /issues payload to the fields this script needs. - * Returns undefined for pull requests and for anything whose title is not a - * `LC ...` problem, which is how non-curriculum rows get skipped. - */ -function readProblemIssue( - value: unknown, -): { lc: number; issue: ProblemIssue } | undefined { - if (!value || typeof value !== "object") return; - if ("pull_request" in value) return; // the /issues route also lists PRs - if (!("number" in value) || typeof value.number !== "number") return; - if (!("title" in value) || typeof value.title !== "string") return; - const lc = value.title.match(/^LC (\d+) /); - if (!lc) return; - - let set = "—"; - if ("labels" in value && Array.isArray(value.labels)) { - for (const label of value.labels) { - if ( - label && - typeof label === "object" && - "name" in label && - typeof label.name === "string" && - label.name.startsWith("set:") - ) { - set = label.name; - } - } - } - return { lc: Number(lc[1]), issue: { number: value.number, set } }; -} - -/** LC number -> open issue carrying the `problem` label. */ -const open = new Map(); -for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=open`)) { - const parsed = readProblemIssue(raw); - if (parsed) open.set(parsed.lc, parsed.issue); -} - -// ── reconcile ──────────────────────────────────────────────────── - -const sha = process.env.GITHUB_SHA; -const report: string[][] = []; -let closed = 0; - -for (const num of [...work.keys()].sort((a, b) => a - b)) { - const entry = work.get(num)!; - const issue = open.get(num); - - // Warm-ups and out-of-curriculum practice have no issue. Not an error. - if (!issue) { - report.push([`${num}`, "—", "—", "no open issue"]); - continue; - } - if (!entry.implemented) { - report.push([`${num}`, `#${issue.number}`, issue.set, "stub — skipped"]); - continue; - } - if (DRY) { - report.push([`${num}`, `#${issue.number}`, issue.set, "would close"]); - continue; - } - - const links = entry.files - .map((f) => - sha ? `[\`${f}\`](https://github.com/${gh.repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``, - ) - .join(", "); - await gh.closeIssue( - issue.number, - `Solved — solution committed at ${links}.\n\n` + - "Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.", - ); - report.push([`${num}`, `#${issue.number}`, issue.set, "closed"]); - closed++; -} - -// ── report ─────────────────────────────────────────────────────── - -const rows = [["lc", "issue", "set", "status"], ...report]; -printTable(rows); - -const implemented = [...work.values()].filter((e) => e.implemented).length; -const actionable = report.filter((r) => r[3] !== "no open issue"); -console.log( - `\n${work.size} in work/ · ${implemented} implemented · ` + - `${actionable.length} matched an open issue · ` + - `${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}`, -); - -await writeStepSummary( - `### close-solved${DRY ? " (dry run)" : ""}\n\n` + - `${work.size} files in \`work/\`, ${implemented} implemented, ` + - `${actionable.length} matched an open issue.\n\n` + - (actionable.length - ? `${markdownTable([rows[0]!, ...actionable])}\n` - : "Nothing to close.\n"), -); diff --git a/scripts/close-topics.ts b/scripts/close-topics.ts deleted file mode 100755 index 6df530b..0000000 --- a/scripts/close-topics.ts +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bun -/** - * Close the GitHub issue for every topic whose required (core) problems are done. - * - * Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each - * problem carries exactly one `set:` label — `set:core` is required, while - * `set:optional` and `set:deferred` are extra credit. A topic is finished when - * every one of its core sub-issues is closed; optional/deferred state is - * ignored, matching the day rule in each topic body ("Core problems first"). - * - * Like close-solved.ts this reconciles state instead of reacting to an event - * payload: re-runs are no-ops, backfilling needs no special casing, and only - * open topics are touched, so reopening a problem never reopens its topic. - * - * bun scripts/close-topics.ts # close finished topics - * bun scripts/close-topics.ts --dry-run # report only, touch nothing - * - * Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`. - */ -import { github } from "./github.ts"; -import { markdownTable, printTable, writeStepSummary } from "./report.ts"; - -const DRY = - process.argv.includes("--dry-run") || - process.env.DRY_RUN === "1" || - process.env.DRY_RUN === "true"; - -const gh = await github(); - -// ── open topic issues ──────────────────────────────────────────── - -interface Topic { - number: number; - title: string; -} - -/** - * Narrow one element of the /issues payload to the fields this script needs. - * The `topic` label filter cannot exclude pull requests, so drop those here. - */ -function readTopic(value: unknown): Topic | undefined { - if (!value || typeof value !== "object") return; - if ("pull_request" in value) return; // the /issues route also lists PRs - if (!("number" in value) || typeof value.number !== "number") return; - if (!("title" in value) || typeof value.title !== "string") return; - return { number: value.number, title: value.title }; -} - -const topics: Topic[] = []; -for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=open`)) { - const topic = readTopic(raw); - if (topic) topics.push(topic); -} -topics.sort((a, b) => a.number - b.number); - -// ── core sub-issue state per topic ─────────────────────────────── - -interface Core { - /** Core sub-issues, closed and open alike, lowest number first. */ - all: number[]; - /** The core sub-issues still open — non-empty means the topic stays open. */ - pending: number[]; -} - -/** - * Read a topic's core sub-issue state. The sub_issues payload carries the full - * issue objects (state + labels), so no per-problem follow-up request is needed. - */ -async function readCore(topic: number): Promise { - const all: number[] = []; - const pending: number[] = []; - for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) { - if (!raw || typeof raw !== "object") continue; - if (!("number" in raw) || typeof raw.number !== "number") continue; - if (!("state" in raw) || typeof raw.state !== "string") continue; - if (!("labels" in raw) || !Array.isArray(raw.labels)) continue; - - const core = raw.labels.some( - (label) => - label && - typeof label === "object" && - "name" in label && - label.name === "set:core", - ); - if (!core) continue; - - all.push(raw.number); - if (raw.state === "open") pending.push(raw.number); - } - all.sort((a, b) => a - b); - pending.sort((a, b) => a - b); - return { all, pending }; -} - -// ── reconcile ──────────────────────────────────────────────────── - -const report: string[][] = []; -let closed = 0; - -for (const topic of topics) { - const core = await readCore(topic.number); - const progress = `${core.all.length - core.pending.length}/${core.all.length}`; - - // A topic with no core sub-issues has nothing to complete: never close it, - // since that would be indistinguishable from a mis-labelled problem set. - if (core.all.length === 0) { - report.push([`#${topic.number}`, topic.title, progress, "no core set"]); - continue; - } - if (core.pending.length > 0) { - report.push([ - `#${topic.number}`, - topic.title, - progress, - `open: ${core.pending.map((n) => `#${n}`).join(" ")}`, - ]); - continue; - } - if (DRY) { - report.push([`#${topic.number}`, topic.title, progress, "would close"]); - continue; - } - - await gh.closeIssue( - topic.number, - `Core set complete — all ${core.all.length} core problems closed ` + - `(${core.all.map((n) => `#${n}`).join(", ")}).\n\n` + - "Closed automatically by `close-topics`. Optional and deferred problems " + - "stay open as extra credit.", - ); - report.push([`#${topic.number}`, topic.title, progress, "closed"]); - closed++; -} - -// ── report ─────────────────────────────────────────────────────── - -const rows = [["topic", "title", "core", "status"], ...report]; -printTable(rows); - -const finished = report.filter((r) => r[3] === "closed" || r[3] === "would close"); -console.log( - `\n${topics.length} open topics · ` + - `${DRY ? `would close ${finished.length}` : `closed ${closed}`}`, -); - -await writeStepSummary( - `### close-topics${DRY ? " (dry run)" : ""}\n\n` + - `${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` + - (report.length ? `${markdownTable(rows)}\n` : "No open topics.\n"), -); diff --git a/scripts/github.ts b/scripts/github.ts deleted file mode 100644 index 145af82..0000000 --- a/scripts/github.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * GitHub REST plumbing shared by the issue reconcilers (close-solved, - * close-topics). - * - * Importing this module is side-effect free — nothing resolves credentials or - * touches the network until github() is awaited — so a script can import it - * without inheriting another script's pipeline. - * - * Repo: GITHUB_REPOSITORY, else the `origin` remote. - * Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token`. - */ -import { $ } from "bun"; -import { join } from "node:path"; - -const ROOT = join(import.meta.dir, ".."); - -/** Page size used for every list endpoint; also the "more pages" threshold. */ -const PER_PAGE = 100; - -export interface GitHub { - /** `owner/name`. */ - repo: string; - /** One authenticated request against api.github.com; throws on non-2xx. */ - api(path: string, init?: RequestInit): Promise; - /** Every page of a list endpoint, flattened into one stream of elements. */ - list(path: string): AsyncGenerator; - /** Comment on an issue, then close it as completed. */ - closeIssue(issue: number, comment: string): Promise; -} - -async function resolveRepo(): Promise { - if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY; - const url = (await $`git -C ${ROOT} remote get-url origin`.text()).trim(); - const m = url.match(/github\.com[:/](.+?)(?:\.git)?$/); - if (!m) throw new Error(`cannot derive owner/repo from remote: ${url}`); - return m[1]!; -} - -async function resolveToken(): Promise { - const env = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; - if (env) return env; - const token = (await $`gh auth token`.text()).trim(); - if (!token) throw new Error("no credentials: set GH_TOKEN or run `gh auth login`"); - return token; -} - -export async function github(): Promise { - const repo = await resolveRepo(); - const token = await resolveToken(); - - async function api(path: string, init: RequestInit = {}): Promise { - const res = await fetch(`https://api.github.com${path}`, { - ...init, - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - "x-github-api-version": "2022-11-28", - ...(init.body ? { "content-type": "application/json" } : {}), - }, - }); - if (!res.ok) { - throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`); - } - return res.json(); - } - - async function* list(path: string): AsyncGenerator { - const sep = path.includes("?") ? "&" : "?"; - for (let page = 1; ; page++) { - const batch = await api(`${path}${sep}per_page=${PER_PAGE}&page=${page}`); - if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`); - yield* batch; - if (batch.length < PER_PAGE) return; - } - } - - /** - * Comment before closing: if the PATCH fails, the issue still carries a - * visible note of what the automation decided, instead of failing silently. - */ - async function closeIssue(issue: number, comment: string): Promise { - await api(`/repos/${repo}/issues/${issue}/comments`, { - method: "POST", - body: JSON.stringify({ body: comment }), - }); - await api(`/repos/${repo}/issues/${issue}`, { - method: "PATCH", - body: JSON.stringify({ state: "closed", state_reason: "completed" }), - }); - } - - return { repo, api, list, closeIssue }; -} diff --git a/scripts/pick.ts b/scripts/pick.ts deleted file mode 100755 index 96673c8..0000000 --- a/scripts/pick.ts +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bun -/** - * Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli. - * Problem index is fetched from leetcode.com and cached for 24h in .cache/. - */ -import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; - -const ROOT = join(import.meta.dir, ".."); -const CACHE_FILE = join(ROOT, ".cache", "leetcode-problems.json"); -const CACHE_TTL_MS = 24 * 60 * 60 * 1000; - -interface Problem { - id: number; - title: string; - slug: string; - difficulty: "Easy" | "Medium" | "Hard"; - paidOnly: boolean; -} - -const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const; - -async function loadProblems(): Promise { - const file = Bun.file(CACHE_FILE); - if (await file.exists()) { - const stale = Date.now() - file.lastModified > CACHE_TTL_MS; - if (!stale) return file.json(); - } - - const s = spinner(); - s.start("Fetching problem index from leetcode.com"); - const res = await fetch("https://leetcode.com/api/problems/all/", { - headers: { "user-agent": "Mozilla/5.0" }, - }); - if (!res.ok) { - s.error(`Fetch failed: ${res.status} ${res.statusText}`); - process.exit(1); - } - const data = (await res.json()) as { - stat_status_pairs: Array<{ - stat: { - frontend_question_id: number; - question__title: string; - question__title_slug: string; - }; - difficulty: { level: 1 | 2 | 3 }; - paid_only: boolean; - }>; - }; - const problems: Problem[] = data.stat_status_pairs - .map((p) => ({ - id: p.stat.frontend_question_id, - title: p.stat.question__title, - slug: p.stat.question__title_slug, - difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"], - paidOnly: p.paid_only, - })) - .sort((a, b) => a.id - b.id); - s.stop(`Loaded ${problems.length} problems`); - - await mkdir(join(ROOT, ".cache"), { recursive: true }); - await Bun.write(CACHE_FILE, JSON.stringify(problems)); - return problems; -} - -const problems = await loadProblems(); - -const picked = await autocomplete({ - message: "Pick a problem", - placeholder: "Type to search by number or title...", - maxItems: 12, - options: problems.map((p) => ({ - value: p, - label: `${p.id}. ${p.title}`, - hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty, - })), - filter: (search, option) => { - const haystack = option.label!.toLowerCase(); - return search - .toLowerCase() - .split(/\s+/) - .every((token) => haystack.includes(token)); - }, -}); - -if (isCancel(picked)) { - cancel("Nothing picked."); - process.exit(0); -} - -const proc = Bun.spawn( - [join(ROOT, "node_modules", ".bin", "leetcode"), "pick", picked.slug], - { cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] }, -); -process.exit(await proc.exited); diff --git a/scripts/report.ts b/scripts/report.ts deleted file mode 100644 index ad77cc7..0000000 --- a/scripts/report.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Console + GitHub Actions reporting shared by the issue reconcilers. - * - * Both reconcilers end the same way: an aligned table on stdout, and the same - * table as Markdown in the step summary when running under Actions. - */ - -/** Print rows[0] as a header, a rule, then the body — every column padded. */ -export function printTable(rows: string[][]): void { - const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length))); - const render = (r: string[]) => - r - .map((cell, i) => cell.padEnd(widths[i]!)) - .join(" ") - .trimEnd(); - - console.log(render(rows[0]!)); - console.log(widths.map((n) => "─".repeat(n)).join(" ")); - for (const row of rows.slice(1)) console.log(render(row)); -} - -/** Same rows as a GitHub-flavoured Markdown table; rows[0] is the header. */ -export function markdownTable(rows: string[][]): string { - const [header, ...body] = rows; - return [ - `| ${header!.join(" | ")} |`, - `| ${header!.map(() => "---").join(" | ")} |`, - ...body.map((r) => `| ${r.join(" | ")} |`), - ].join("\n"); -} - -/** Append to the Actions step summary; a no-op outside Actions. */ -export async function writeStepSummary(markdown: string): Promise { - const path = process.env.GITHUB_STEP_SUMMARY; - if (!path) return; - await Bun.write(path, markdown); -} diff --git a/scripts/sync.ts b/scripts/sync.ts deleted file mode 100755 index f274348..0000000 --- a/scripts/sync.ts +++ /dev/null @@ -1,407 +0,0 @@ -#!/usr/bin/env bun -/** - * Sync work/ leetcode solutions into docs/solutions/ pages. - * - * - New problems get a full page (frontmatter, badge, warning, examples, - * constraints, solution) in the gold-standard format. - * - Already-ported pages only get their `## Solution` section regenerated, - * so hand-curated prose is never touched. - * - When a problem is solved in both JavaScript and Python the solution - * renders as a (Python first); single-language solutions - * render as a plain fence. - */ -import { mkdir, rename } from "node:fs/promises"; -import { basename, join } from "node:path"; - -const ROOT = join(import.meta.dir, ".."); -const WORK = join(ROOT, "work"); -const DOCS = join(ROOT, "docs", "solutions"); - -type Lang = "js" | "py"; - -interface Example { - input: string; - output: string; - explanation: string[]; // [] = none; 1 entry = inline; >1 = bullet list -} - -interface Header { - num: number; - title: string; - difficulty: string; - statement: string[]; // unwrapped paragraphs - examples: Example[]; - constraints: string[]; - followUp: string; -} - -interface Solution { - num: number; - slug: string; - category: string; - header: Header; - code: Partial>; -} - -// ── work/ parsing ──────────────────────────────────────────────── - -/** Split a source file into (header comment text, code). */ -function splitSource(src: string, lang: Lang): { header: string; code: string } { - let header: string, code: string; - if (lang === "js") { - const end = src.indexOf("*/"); - if (!src.trimStart().startsWith("/*") || end === -1) throw new Error("missing /* header */"); - header = src - .slice(src.indexOf("/*") + 2, end) - .split("\n") - .map((l) => l.replace(/^\s*\* ?/, "")) - .join("\n"); - code = src.slice(end + 2); - } else { - const open = src.indexOf('"""'); - const close = src.indexOf('"""', open + 3); - if (open === -1 || close === -1) throw new Error('missing """ header """'); - header = src.slice(open + 3, close); - code = src.slice(close + 3); - } - return { header, code: code.replace(/^\s*\n/, "").trimEnd() }; -} - -/** Unwrap hard-wrapped lines into logical paragraphs / bullets. */ -function paragraphs(lines: string[]): string[] { - const out: string[] = []; - let cur = ""; - const flush = () => { - if (cur) out.push(cur); - cur = ""; - }; - for (const raw of lines) { - const line = raw.replace(/\t/g, " ").trimEnd(); - const text = line.trim(); - if (!text || /^[─-]{3,}$/.test(text)) { - flush(); - continue; - } - if (/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(text)) { - flush(); - cur = text; - } else { - cur = cur ? `${cur} ${text}` : text; - } - } - flush(); - return out; -} - -function parseHeader(header: string): Header { - const lines = header.split("\n"); - const titleLine = lines.find((l) => /^\s*\d+\.\s/.test(l))?.trim(); - if (!titleLine) throw new Error("missing '. ' line"); - const num = Number.parseInt(titleLine, 10); - const title = titleLine.replace(/^\d+\.\s*/, ""); - const difficulty = - lines.find((l) => l.trim().startsWith("Difficulty:"))?.split(":")[1]?.trim() ?? ""; - - // Body: everything after the ───── separator. - const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim())); - const paras = paragraphs(lines.slice(sep + 1)); - - const statement: string[] = []; - const examples: Example[] = []; - const constraints: string[] = []; - let followUp = ""; - let section: "statement" | "example" | "constraints" = "statement"; - let ex: Example | null = null; - - for (const p of paras) { - if (/^Example \d+:?$/.test(p)) { - if (ex) examples.push(ex); - ex = { input: "", output: "", explanation: [] }; - section = "example"; - continue; - } - if (/^Constraints:$/.test(p)) { - if (ex) examples.push(ex); - ex = null; - section = "constraints"; - continue; - } - const fu = p.match(/^Follow[- ]?ups?:\s*(.*)$/i); - if (fu) { - followUp = fu[1]!; - continue; - } - if (section === "statement") statement.push(p.replace(/^• /, "")); - else if (section === "constraints") constraints.push(p.replace(/^• /, "")); - else if (ex) { - // Example paragraphs: Input/Output/Explanation, wrapped arbitrarily. - // A paragraph may fuse "Input: … Output: …" only across real lines, - // but source always keeps them on separate wrapped paragraphs. - if (p.startsWith("Input:")) ex.input = p.slice(6).trim(); - else if (p.startsWith("Output:")) ex.output = p.slice(7).trim(); - else if (p.startsWith("Explanation:")) { - const rest = p.slice(12).trim(); - if (rest) ex.explanation.push(rest); - } else if (p.startsWith("- ")) ex.explanation.push(p.slice(2)); - else if (ex.explanation.length) - ex.explanation[ex.explanation.length - 1] += ` ${p}`; - else ex.explanation.push(p); - } - } - if (ex) examples.push(ex); - return { num, title, difficulty, statement, examples, constraints, followUp }; -} - -// ── README topic map ───────────────────────────────────────────── - -const TOPIC_ALIASES: Record<string, string> = { - "Hash-Based Lookup": "Hash Table", -}; - -async function readmeTopics(): Promise<Map<number, string>> { - const md = await Bun.file(join(ROOT, "README.md")).text(); - const map = new Map<number, string>(); - let topic = ""; - for (const line of md.split("\n")) { - const t = line.match(/<strong>\d+\.\s*([^<]+)<\/strong>/); - if (t) topic = TOPIC_ALIASES[t[1]!.trim()] ?? t[1]!.trim(); - if (!topic) continue; - const n = line.match(/<td align="center">(\d+)<\/td>/); - if (n) map.set(Number(n[1]), topic); - } - return map; -} - -// ── inline-code formatting heuristics ──────────────────────────── - -/** Identifiers worth backticking in prose, harvested from the code. */ -function identifiers(sol: Solution): Set<string> { - const ids = new Set<string>(["n", "m", "k"]); - 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(/def \w+\(self,?\s*([^)]*)\)/g)) - for (const arg of m[1]!.split(",")) - if (arg.trim()) ids.add(arg.split(":")[0]!.trim()); - for (const m of code.matchAll(/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g)) - for (const arg of m[2]!.split(",")) - if (arg.trim()) ids.add(arg.trim()); - } - return ids; -} - -function formatConstraint(c: string, ids: Set<string>): string { - if (/<=|>=|==|!=|<|>/.test(c)) return `\`${c}\``; - // Prose constraint: backtick identifier-ish tokens only. - return c - .split(" ") - .map((word) => { - const m = word.match(/^([\w.]+(?:\[[^\]]*\])?)([.,;:]?)$/); - if (!m) return word; - const [, tok, punct] = m; - if (/\[[^\]]*\]/.test(tok!) || ids.has(tok!)) return `\`${tok}\`${punct}`; - return word; - }) - .join(" "); -} - -/** Wrap value literals / expressions in an explanation sentence. */ -function backtickify(text: string, ids: Set<string>): string { - const words = text.split(" "); - type Tok = { pre: string; core: string; post: string; codey: boolean }; - const toks: Tok[] = words.map((w) => { - const m = w.match(/^([("']*)(.*?)([)"'.,;:!?]*)$/)!; - const core = m[2]!; - const codey = - /^-?\d+(\.\d+)?([+\-*/]-?\d+(\.\d+)?)*$/.test(core) || // number / compact arithmetic - /^\[[^\]]*\]$/.test(core) || // array literal - /^[a-zA-Z_]\w*\[[^\]]*\]$/.test(core) || // indexed identifier - /^[+\-*/=%]$/.test(core) || // operator - ids.has(core); - return { pre: m[1]!, core, post: m[3]!, codey }; - }); - - const out: string[] = []; - let i = 0; - while (i < toks.length) { - if (!toks[i]!.codey || /^[+\-*/=%]$/.test(toks[i]!.core)) { - out.push(words[i]!); // prose, or an operator with no codey run to its left - i++; - continue; - } - // Extend a run of codey tokens; only unbroken by punctuation. - let j = i; - while ( - j + 1 < toks.length && - toks[j + 1]!.codey && - !toks[j]!.post && // punctuation after a token ends the run - !toks[j + 1]!.pre.includes('"') - ) - j++; - // Trim trailing operators from the run (e.g. "5 and" keeps "and" out anyway). - while (j > i && /^[+\-*/=%]$/.test(toks[j]!.core)) j--; - const span = toks - .slice(i, j + 1) - .map((tok, idx, arr) => (idx === arr.length - 1 ? `${tok.pre}${tok.core}` : `${tok.pre}${tok.core}${tok.post}`)) - .join(" "); - out.push(`\`${span}\`${toks[j]!.post}`); - i = j + 1; - } - return out.join(" "); -} - -// ── page rendering ─────────────────────────────────────────────── - -const FENCE: Record<Lang, { info: string; label: string }> = { - py: { info: "py", label: "Python" }, - js: { info: "js", label: "JavaScript" }, -}; - -function solutionSection(sol: Solution): string { - const langs = (["py", "js"] as const).filter((l) => sol.code[l]); - const fence = (l: Lang, titled: boolean) => - `\`\`\`${FENCE[l].info}${titled ? ` ${FENCE[l].label}` : ""}\n${sol.code[l]}\n\`\`\``; - if (langs.length === 1) return `## Solution\n\n${fence(langs[0]!, false)}\n`; - return `## Solution\n\n<CodeGroup>\n\n${langs.map((l) => fence(l, true)).join("\n\n")}\n\n</CodeGroup>\n`; -} - -function warningText(h: Header): string { - if (h.followUp) return h.followUp; - return ( - h.statement - .slice(1) - .find((p) => /^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(p)) ?? "" - ); -} - -function renderPage(sol: Solution, topic: string): string { - const h = sol.header; - const ids = identifiers(sol); - const description = h.statement[0]?.replace(/\.\s*$/, "") ?? ""; - const parts: string[] = []; - - parts.push( - "---", - `title: '${h.num}. ${h.title.replaceAll("'", "''")}'`, - /[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`, - "sidebar:", - ` label: '${h.title.replaceAll("'", "''")}'`, - ` badge: '${h.difficulty}'`, - "---", - "", - `<Badge variant="accent">${topic}</Badge>`, - "", - ); - - const warning = warningText(h); - if (warning) parts.push("::::warning", warning, "::::", ""); - - h.examples.forEach((ex, i) => { - parts.push(`### Example ${i + 1}:`); - parts.push(`- Input: \`${ex.input}\``); - parts.push(`- Output: \`${ex.output}\``); - if (ex.explanation.length === 1) - parts.push(`- Explanation: ${backtickify(ex.explanation[0]!, ids)}`); - else if (ex.explanation.length > 1) { - parts.push("- Explanation:"); - for (const item of ex.explanation) parts.push(` - ${backtickify(item, ids)}`); - } - parts.push(""); - }); - - parts.push("### Constraints:", ""); - for (const c of h.constraints) parts.push(`- ${formatConstraint(c, ids)}`); - parts.push("", solutionSection(sol)); - - return parts.join("\n"); -} - -// ── main ───────────────────────────────────────────────────────── - -const solutions = new Map<number, Solution>(); -for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) { - const lang = rel.endsWith(".py") ? "py" : ("js" as Lang); - const file = basename(rel); - const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/); - if (!m) { - console.warn(`skip (unrecognized name): work/${rel}`); - continue; - } - const num = Number(m[1]); - const slug = m[2]!; - const { header, code } = splitSource(await Bun.file(join(WORK, rel)).text(), lang); - const parsed = parseHeader(header); - const existing = solutions.get(num); - if (existing) { - existing.code[lang] = code; - if (lang === "js") existing.header = parsed; // js header wins when both exist - } else { - solutions.set(num, { - num, - slug, - category: rel.split("/")[1] ?? "", - header: parsed, - code: { [lang]: code }, - }); - } -} - -const topics = await readmeTopics(); - -/** Insert a number-free `sidebar.label` into existing frontmatter when missing. */ -function ensureLabel(page: string, h: Header): string { - const fm = page.match(/^---\n[\s\S]*?\n---/); - if (!fm || /^ {2}label: /m.test(fm[0])) return page; - const label = ` label: '${h.title.replaceAll("'", "''")}'`; - return page.replace(/^sidebar:$/m, `sidebar:\n${label}`); -} - -// Existing docs pages, keyed by leetcode number from the frontmatter title. -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()) { - const text = await Bun.file(join(DOCS, rel)).text(); - const t = text.match(/^title: '(\d+)\./m); - if (t) pages.set(Number(t[1]), rel); -} - -const report: [string, string, string][] = []; -const ordered = [...solutions.values()].sort((a, b) => a.num - b.num); - -for (const sol of ordered) { - const langs = Object.keys(sol.code).sort().join("+"); - const workRef = `${sol.num}.${sol.slug} (${langs})`; - // (category) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL. - const group = `(${sol.category.toLowerCase().replaceAll(" ", "-")})`; - const name = `${group}/${sol.num}-${sol.slug}.mdx`; - const existing = pages.get(sol.num); - await mkdir(join(DOCS, group), { recursive: true }); - - if (existing) { - const page = await Bun.file(join(DOCS, existing)).text(); - const at = page.indexOf("## Solution"); - if (at === -1) { - report.push([workRef, existing, "ERROR: no '## Solution' heading"]); - continue; - } - const next = ensureLabel(page.slice(0, at) + solutionSection(sol), sol.header); - const actions: string[] = []; - if (existing !== name) { - await rename(join(DOCS, existing), join(DOCS, name)); - actions.push("moved"); - } - if (next !== page) { - await Bun.write(join(DOCS, name), next); - actions.push("updated"); - } - report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]); - } else { - const topic = topics.get(sol.num) ?? sol.category; - await Bun.write(join(DOCS, name), renderPage(sol, topic)); - report.push([workRef, name, "created"]); - } -} - -const w0 = Math.max(...report.map((r) => r[0].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(`${"─".repeat(w0)} ${"─".repeat(w1)} ${"─".repeat(20)}`); -for (const [a, b, c] of report) console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`); diff --git a/scripts/test.ts b/scripts/test.ts deleted file mode 100755 index 14b1732..0000000 --- a/scripts/test.ts +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bun -/** - * Fuzzy-select a solution file from work/ and run leetcode-cli tests on it. - */ -import { autocomplete, cancel, isCancel, log } from "@clack/prompts"; -import { join, relative } from "node:path"; - -const ROOT = join(import.meta.dir, ".."); -const WORK_DIR = join(ROOT, "work"); - -const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })] - .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); - -if (files.length === 0) { - log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`); - process.exit(1); -} - -const picked = await autocomplete<string>({ - message: "Test which solution?", - placeholder: "Type to search...", - maxItems: 12, - options: files.map((f) => { - // work layout: Difficulty/Category/<id>.<slug>.<ext> - const [difficulty, category, name] = f.split("/"); - return { - value: f, - label: name ?? f, - hint: category ? `${difficulty} · ${category}` : difficulty, - }; - }), - filter: (search, option) => { - const haystack = option.value.toLowerCase(); - return search - .toLowerCase() - .split(/\s+/) - .every((token) => haystack.includes(token)); - }, -}); - -if (isCancel(picked)) { - cancel("Nothing selected."); - process.exit(0); -} - -const proc = Bun.spawn( - [ - join(ROOT, "node_modules", ".bin", "leetcode"), - "test", - join(WORK_DIR, picked), - ], - { cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] }, -); -process.exit(await proc.exited);