Compare commits

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

No files matched your search

+3 -3
View File
@@ -5,7 +5,7 @@ description: Write the `## Approach` section — a Mermaid flowchart that traces
# Problem approach diagrams
Every solution page (`apps/docs/content/(<phase>)/<topic>/<num>-<slug>.mdx`) has a
Every solution page (`apps/docs/content/(<phase>)/<topic>/(problems)/<num>_<slug>.mdx`) has a
hand-owned section before the script-owned one:
```
@@ -100,7 +100,7 @@ flowchart TD
The base case is the first decision after entry.
- **Classes** (design problems) use one `subgraph name["method(args)"]` per public
method, plus a `state["…"]` subgraph describing the fields, with `state --> method`
edges — see `(hash-table)/146-lru-cache.mdx`. Private helpers are named inside the
edges — see `hybrid-structures/(problems)/146_lru-cache.mdx`. Private helpers are named inside the
method nodes (`"_remove_node then _add_node — move it beside the tail"`), not drawn.
- **Annotate the why with an em dash** inside the node, sparingly (one clause, a few
per diagram): `"nums.sort() — duplicates become adjacent"`, `"map = {0: 1} — the
@@ -122,7 +122,7 @@ flowchart TD
### Code that does not run
Trace it anyway — the diagram documents what was submitted — and put a `::::warning`
callout **before** the fence naming exactly why it fails (`(array)/121-best-time-to-buy-and-sell-stock.mdx`):
callout **before** the fence naming exactly why it fails (`sliding-window/(problems)/121_best-time-to-buy-and-sell-stock.mdx`):
````md
## Approach
+3 -3
View File
@@ -48,7 +48,7 @@ flowchart LR
|---|---|
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment (`/tree-dfs/invert-binary-tree`), the `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its Appendix guide page `/appendix/<slug>` (outlined; LeetCode's problem list if the guide is gone, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows in the sidebar (read off `data.navigation`: the topic `meta.ts` icon, the guide's `icon:`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a page so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing and guide page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components (so badges and backlinks cannot disagree) and the map by sync. `content/appendix/` (the sidebar's `Appendix` row, right under Progress: `order: 0` ahead of the phase groups, `display: "page"` so it drills into its own panel — no header tab) is one page per tag outside that map, filed by hand into `(group)` categories — `(1-data-structures)/`, `(2-algorithms)/`, `(3-techniques)/` (hand-written `meta.ts` each; no route segment, so every guide stays `/appendix/<slug>`). Sync finds a guide by slug anywhere under `appendix/` and creates a placeholder only when none exists or it is empty — at the `appendix/` root, for you to file into a category and give a Lucide `icon:` — the one every badge linking to it shows — that no topic and no other guide uses (sync warns while it is missing, a `TOPIC_ICONS` value, or another guide's) — and after that owns exactly one key of it: `sidebar.badge`, the sidebar count, rewritten every run to the guide's backlink count (problem pages carrying the tag — equal to the `BacklinksList` rows, since a guide's tag has no landing; dropped at zero). Everything else is hand-owned (a guide whose tag no page carries any more is left alone, badge dropped), with frontmatter `list:` — the tag's LeetCode problem list, rendered by the same wrapper as an orange `LeetCode list` badge; sync also writes `appendix/meta.ts`. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; the five `/progress` cards `SolvedRing`/`SubmissionHeatmap`/`SubmissionChart`/`TopicBubbles`/`RecentSolves`, and `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigation.json` reports for the topic folder, inert until a topic has pages), `components/<Name>.astro` registered in `components.ts` is static markup plus a custom element for behaviour, the way Blume's own `Frame` is built (`Carousel`/`CarouselImage`: `src` takes a URL, `/file.png`, or the page-relative `../../public/file.png`) |
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `pages/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/(problems)/<num>_<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment, and `(problems)` is a second `(group)` inside it, so `tree-dfs/(problems)/226_invert-binary-tree.mdx` routes to `/tree-dfs/invert-binary-tree` (Blume sorts by the `<num>_` prefix and strips it from the route). The `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order; every topic meta is `display: "page"`, so a topic is one sidebar row whose panel holds its landing page and a "Problems" section. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its card on the Appendix glossary `/appendix#<slug>` (outlined; LeetCode's problem list if the tag has no term, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows (a landing's: the topic `meta.ts` icon read off `data.navigation`; an Appendix term's: its `icon` in `appendix.ts`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a landing or a term so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components and the Appendix page (so badges, backlinks and term cards cannot disagree) and the map by sync. Every other tag a problem carries is ONE hand-written table, `apps/docs/appendix.ts` (`APPENDIX`: slug → `{ name, icon, category, definition }`), rendered by ONE custom page, `pages/appendix.astro` — a glossary with an A–Z letter index and a filter box (an `<appendix-filter>` custom element over static markup; no JS, the full list), one card per term (icon tinted the tag's colour, category, definition, LeetCode list link, and a `<details>` "Solved" list of the problem pages whose badge `tagTarget()` sends there, paged `PAGE_SIZE` (5) rows at a time by numbered buttons under it — a `<solved-pages>` custom element; without JS every row shows and the buttons stay hidden), each card `id`'d by slug and highlighted as `:target`. It is no content folder, so it enters neither the sidebar tree nor prev/next: `navigation.featured` pins it above the tree. Sync writes nothing for it — it warns about a carried tag with no term, a term whose tag a topic now teaches (delete it), and a term `icon` that is a `TOPIC_ICONS` value or another term's. The former per-term pages `/appendix/<slug>` are exact redirects to `/appendix#<slug>` in `blume.config.ts`, generated from `APPENDIX`'s keys. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; the five `/progress` cards `SolvedRing`/`SubmissionHeatmap`/`SubmissionChart`/`TopicBubbles`/`RecentSolves`, and `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigation.json` reports for the topic folder, inert until a topic has pages), `components/<Name>.astro` registered in `components.ts` is static markup plus aLine truncated
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
| `.github/workflows/` | deploy (docs → Pages with `GITHUB_TOKEN` for the releases-backed changelog, Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
| `.githooks/` | Tracked git hooks; `bun install` points `core.hooksPath` here (root `prepare` script, no husky). `pre-commit` runs `bun run sync` and stages only what sync changed (pages, `apps/docs/redirects.ts`), so a solution, its page and its `/<num>` short link land in one commit; it refuses to run while `work/1` has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under `apps/docs/content` (in-progress `## Approach` prose) unstaged. `git commit --no-verify` for a deliberate partial commit |
@@ -113,10 +113,10 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`. The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`, with every problem page in the topic's `(problems)/` group as `<num>_<slug>.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "page"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing through an emptied `(problems)` and topic into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` / `apps/docs/lib/stats.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs `/progress` islands and `bun run stats`, so its shape is their contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The docs `/progress` page is five LeetCode-style islands — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many islands hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively — Blume's Vite has no `@/` alias; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The docs `/progress` page is five LeetCode-style islands — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many islands hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones (house style) — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume turns every tsconfig `paths` target into a Vite alias AND a dev dep-scan glob (`<target>/**/*.astro`…), so `paths` maps only `@/components`, `@/lib`, `@/hooks` — each bare and `/*`, since `components.json` names the bare forms — never `@/*` → `./*`: that glob reaches through `node_modules/blume`, where Vite's scanner reads a `<script>` inside a `RootLayout.astro` comment as a tag and `blume dev` skips dependency pre-bundling. Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
- `apps/docs/blume.config.ts` — site base `/leetcode`; imports `apps/docs/redirects.ts`, sync-generated (`/<num>` → `/<topic>/<slug>`, one row per page, exact root-relative paths — Blume adds the base and, for the static Pages build, emits a meta-refresh page per rule plus `blume-redirects.json`); `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`).
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
+199 -165
View File
@@ -28,17 +28,12 @@
* page is written without it and the next sync retries. Delete the key to
* refetch it.
* - Every tag a page carries that no campaign topic teaches (not a key of
* `apps/docs/topic-landings.ts`) gets a placeholder guide page in the
* Appendix, `appendix/<slug>.mdx`, with `list:` pointing at LeetCode's
* problem list for the tag. Created once (a missing or empty file), then
* hand-owned except for `sidebar.badge`, which sync keeps at the guide's
* backlink count (the problem pages carrying the tag; dropped at zero); the
* tag's badge on a problem page links to it. A guide may be filed into a
* category `(group)` folder (e.g. `appendix/(2-algorithms)/`) — no route
* segment, so `/appendix/<slug>` holds — and is found there by slug; a new
* placeholder lands at the `appendix/` root for a human to file (and give
* an `icon:`, which every badge linking to the guide shows; sync warns
* while one is missing or shared).
* `apps/docs/topic-landings.ts`) belongs in the Appendix glossary,
* `apps/docs/appendix.ts`, which is hand-written: sync only checks it,
* warning about a carried tag with no term (its badge falls back to
* LeetCode's list), a term whose tag a topic now teaches, and a term icon
* that is missing, a topic's (`TOPIC_ICONS`), or another term's — every
* badge linking to the term shows it.
*
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`,
* ordered by topic number via a script-owned `meta.ts`). A problem outside the
@@ -50,11 +45,14 @@
* segment — while the topic folder is a real segment
* (`/tree-dfs/invert-binary-tree`), so a hand-written `index.mdx` in it is the
* topic's landing page at `/tree-dfs` and Blume links the sidebar group row to
* it. Sync never writes or moves an `index.mdx`, and never removes a folder
* that still holds one. A phase folder's `meta.ts` is written once, when
* missing, then hand-owned (its title is yours to rename); a topic's is
* script-owned. An unplanned category group has no phase and sits at the
* content root.
* it. The topic's problem pages sit one level down in a `(problems)` group,
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either,
* and Blume strips the `<num>_` prefix from the route while sorting by it, so
* the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves
* an `index.mdx`, and never removes a folder that still holds one. A phase
* folder's `meta.ts` is written once, when missing, then hand-owned (its
* title is yours to rename); a topic's is script-owned. An unplanned category
* group has no phase and sits at the content root.
*
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
* of problems whose page already exists (see work.ts), and a fresh scaffold
@@ -68,6 +66,7 @@
import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { APPENDIX } from "../docs/appendix.ts";
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
import { github } from "./github.ts";
@@ -105,11 +104,15 @@ interface Solution {
// ── work/ parsing ────────────────────────────────────────────────
/** Split a source file into (header comment text, code). */
function splitSource(src: string, lang: Lang): { header: string; code: string } {
function splitSource(
src: string,
lang: Lang,
): { header: string; code: string } {
let header: string, code: string;
if (lang === "js") {
const end = src.indexOf("*/");
if (!src.trimStart().startsWith("/*") || end === -1) throw new Error("missing /* header */");
if (!src.trimStart().startsWith("/*") || end === -1)
throw new Error("missing /* header */");
header = src
.slice(src.indexOf("/*") + 2, end)
.split("\n")
@@ -141,7 +144,11 @@ function paragraphs(lines: string[]): string[] {
flush();
continue;
}
if (/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(text)) {
if (
/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(
text,
)
) {
flush();
cur = text;
} else {
@@ -159,7 +166,10 @@ function parseHeader(header: string): Header {
const num = Number.parseInt(titleLine, 10);
const title = titleLine.replace(/^\d+\.\s*/, "");
const difficulty =
lines.find((l) => l.trim().startsWith("Difficulty:"))?.split(":")[1]?.trim() ?? "";
lines
.find((l) => l.trim().startsWith("Difficulty:"))
?.split(":")[1]
?.trim() ?? "";
// Body: everything after the ───── separator.
const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim()));
@@ -289,10 +299,18 @@ async function readmeTopics(): Promise<Map<number, Topic>> {
const p = line.match(/^## Phase ([IVX]+) — (.+?) · /);
if (p) {
const name = p[2]!.replaceAll("&amp;", "&");
phase = { order: ROMAN.indexOf(p[1]!) + 1, name: PHASE_ALIASES[name] ?? name };
phase = {
order: ROMAN.indexOf(p[1]!) + 1,
name: PHASE_ALIASES[name] ?? name,
};
}
const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/);
if (t) topic = { order: Number(t[1]), name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim(), phase };
if (t)
topic = {
order: Number(t[1]),
name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim(),
phase,
};
if (!topic) continue;
const n = line.match(/<td align="center">(\d+)<\/td>/);
if (n && !map.has(Number(n[1]))) map.set(Number(n[1]), topic);
@@ -341,15 +359,22 @@ function phaseMeta(phase: Phase): string {
/**
* Blume folder meta for a topic: exact label (the folder slug loses `/` and
* case), curriculum order, the sidebar icon, and `display: "group"` so the
* topic collapses under its phase heading. An unplanned category group
* case), curriculum order, the sidebar icon, and `display: "page"` so the
* topic is one row under its phase heading that drills into its own panel
* (the landing page, then its `(problems)` list). An unplanned category group
* (warm-ups) has no README number and sorts at 0, ahead of phase 1, so the
* sidebar opens on the basics.
*/
function groupMeta(topic: Topic): string {
const icon = TOPIC_ICONS[topic.name];
if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`);
const fields = [`title: ${JSON.stringify(topic.name)}`, `order: ${topic.order ?? 0}`];
if (!icon)
console.warn(
`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`,
);
const fields = [
`title: ${JSON.stringify(topic.name)}`,
`order: ${topic.order ?? 0}`,
];
if (icon) fields.push(`icon: ${JSON.stringify(icon)}`);
fields.push(`display: "group"`);
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ ${fields.join(", ")} });\n`;
@@ -378,17 +403,24 @@ async function fetchTopicTags(sol: Solution): Promise<TopicTag[] | undefined> {
referer: `https://leetcode.com/problems/${sol.slug}/`,
},
body: JSON.stringify({
query: "query topicTags($titleSlug: String!) { question(titleSlug: $titleSlug) { topicTags { name slug } } }",
query:
"query topicTags($titleSlug: String!) { question(titleSlug: $titleSlug) { topicTags { name slug } } }",
variables: { titleSlug: sol.slug },
}),
});
if (!res.ok) throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`);
const data = (await res.json()) as { data?: { question?: { topicTags?: TopicTag[] } | null } };
if (!res.ok)
throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`);
const data = (await res.json()) as {
data?: { question?: { topicTags?: TopicTag[] } | null };
};
const tags = data.data?.question?.topicTags;
if (!Array.isArray(tags)) throw new Error("POST /graphql -> no such question");
if (!Array.isArray(tags))
throw new Error("POST /graphql -> no such question");
return tags.map(({ name, slug }) => ({ name, slug }));
} catch (err) {
console.warn(`no topics for ${sol.num}.${sol.slug}: ${err instanceof Error ? err.message : err}`);
console.warn(
`no topics for ${sol.num}.${sol.slug}: ${err instanceof Error ? err.message : err}`,
);
return undefined;
}
}
@@ -396,15 +428,30 @@ async function fetchTopicTags(sol: Solution): Promise<TopicTag[] | undefined> {
/** The frontmatter `topics:` block, one flow mapping per tag (JSON strings are valid YAML). */
function topicsYaml(tags: TopicTag[]): string {
if (tags.length === 0) return "topics: []";
return ["topics:", ...tags.map((t) => ` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`)].join("\n");
return [
"topics:",
...tags.map(
(t) =>
` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`,
),
].join("\n");
}
/** The frontmatter's `topics:` as sync wrote them (or a human edited them); [] when absent. */
function frontmatterTopics(page: string): TopicTag[] {
const fm = page.match(/^---\n([\s\S]*?)\n---/)?.[1];
const data: unknown = fm ? Bun.YAML.parse(fm) : undefined;
if (!data || typeof data !== "object" || !("topics" in data) || !Array.isArray(data.topics)) return [];
return data.topics.filter((t): t is TopicTag => typeof t?.name === "string" && typeof t?.slug === "string");
if (
!data ||
typeof data !== "object" ||
!("topics" in data) ||
!Array.isArray(data.topics)
)
return [];
return data.topics.filter(
(t): t is TopicTag =>
typeof t?.name === "string" && typeof t?.slug === "string",
);
}
// ── GitHub problem issues ────────────────────────────────────────
@@ -422,7 +469,9 @@ function problemIssues(): Promise<Map<number, string> | undefined> {
try {
const gh = await github();
const urls = new Map<number, string>();
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=all`)) {
for await (const raw of gh.list(
`/repos/${gh.repo}/issues?labels=problem&state=all`,
)) {
if (!raw || typeof raw !== "object" || "pull_request" in raw) continue;
if (!("title" in raw) || typeof raw.title !== "string") continue;
if (!("html_url" in raw) || typeof raw.html_url !== "string") continue;
@@ -431,7 +480,9 @@ function problemIssues(): Promise<Map<number, string> | undefined> {
}
return urls;
} catch (err) {
console.warn(`no problem issues: ${err instanceof Error ? err.message : err}`);
console.warn(
`no problem issues: ${err instanceof Error ? err.message : err}`,
);
return undefined;
}
})();
@@ -448,73 +499,28 @@ async function issueUrl(sol: Solution): Promise<string | undefined> {
if (!topics.has(sol.num)) return undefined;
const urls = await problemIssues();
const url = urls?.get(sol.num);
if (urls && !url) console.warn(`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`);
if (urls && !url)
console.warn(
`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`,
);
return url;
}
// ── guides ───────────────────────────────────────────────────────
/**
* Placeholder for a tag no campaign topic teaches: the `/appendix/<slug>` page a
* problem's topic badge links to. `list:` is the tag's LeetCode problem list,
* rendered as a badge by the docs MDX `wrapper`. Written once — the page
* is hand-owned from then on.
*/
function guidePage(tag: TopicTag): string {
return [
"---",
`title: ${JSON.stringify(tag.name)}`,
`list: ${JSON.stringify(`https://leetcode.com/problem-list/${tag.slug}/`)}`,
"---",
"",
":::note",
`This guide has not been written yet. The LeetCode list above has every problem tagged ${tag.name}.`,
":::",
"",
].join("\n");
}
/**
* Folder meta for `appendix/`: a drill-in (`display: "page"`) sidebar row
* sorted ahead of the phase groups, so it sits right under the root pages
* (Introduction, Keywords, Progress).
*/
const APPENDIX_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Appendix", icon: "library", order: 0, display: "page" });\n`;
/**
* A guide's frontmatter with `sidebar.badge` set to its backlink count — the
* one key sync owns on a hand-owned guide — or dropped (with a `sidebar:` left
* empty) at zero, so the sidebar shows a count only where there is something
* to count.
*/
function withBacklinkBadge(page: string, count: number): string {
const fm = page.match(/^---\n[\s\S]*?\n---/);
if (!fm) return page;
let head = fm[0].replace(/^ {2}badge: .*\n/m, "");
if (count > 0) {
const line = ` badge: '${count}'`;
head = /^sidebar:$/m.test(head)
? head.replace(/^sidebar:$/m, `sidebar:\n${line}`)
: head.replace(/\n---$/, `\nsidebar:\n${line}\n---`);
} else {
head = head.replace(/^sidebar:\n(?! {2})/m, "");
}
return head + page.slice(fm[0].length);
}
// ── inline-code formatting heuristics ────────────────────────────
/** Identifiers worth backticking in prose, harvested from the code. */
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(/@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());
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;
}
@@ -571,7 +577,11 @@ function backtickify(text: string, ids: Set<string>): string {
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}`))
.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;
@@ -599,14 +609,22 @@ function warningText(h: Header): string {
return (
h.statement
.slice(1)
.find((p) => /^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(p)) ?? ""
.find((p) =>
/^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(
p,
),
) ?? ""
);
}
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
const PAGE_ICON = "dot";
function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string | undefined): string {
function renderPage(
sol: Solution,
tags: TopicTag[] | undefined,
issue: string | undefined,
): string {
const h = sol.header;
const ids = identifiers(sol);
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
@@ -615,7 +633,9 @@ function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string |
parts.push(
"---",
`title: '${h.num}. ${h.title.replaceAll("'", "''")}'`,
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
/[:#]/.test(description)
? `description: '${description.replaceAll("'", "''")}'`
: `description: ${description}`,
`icon: ${PAGE_ICON}`,
...(tags ? [topicsYaml(tags)] : []),
...(issue ? [`issue: ${JSON.stringify(issue)}`] : []),
@@ -637,7 +657,8 @@ function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string |
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)}`);
for (const item of ex.explanation)
parts.push(` - ${backtickify(item, ids)}`);
}
parts.push("");
});
@@ -662,7 +683,10 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
}
const num = Number(m[1]);
const slug = m[2]!;
const { header, code } = splitSource(await Bun.file(join(WORK, rel)).text(), lang);
const { header, code } = splitSource(
await Bun.file(join(WORK, rel)).text(),
lang,
);
const parsed = parseHeader(header);
const existing = solutions.get(num);
if (existing) {
@@ -711,7 +735,10 @@ async function ensureTopics(page: string, sol: Solution): Promise<string> {
if (!fm || /^topics:/m.test(fm[0])) return page;
const tags = await fetchTopicTags(sol);
if (!tags) return page;
return fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) + page.slice(fm[0].length);
return (
fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) +
page.slice(fm[0].length)
);
}
/** Insert `issue:` before `sidebar:` when the frontmatter lacks it; an existing URL is kept as is. */
@@ -720,7 +747,10 @@ async function ensureIssue(page: string, sol: Solution): Promise<string> {
if (!fm || /^issue:/m.test(fm[0])) return page;
const url = await issueUrl(sol);
if (!url) return page;
return fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\nsidebar:`) + page.slice(fm[0].length);
return (
fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\nsidebar:`) +
page.slice(fm[0].length)
);
}
/**
@@ -743,7 +773,9 @@ function solutionEnd(page: string, from: number): number {
// 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()) {
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);
@@ -756,21 +788,21 @@ const phases = new Map<string, Phase>(); // phase folder -> phase, for meta.ts
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order
const tags = new Map<string, TopicTag>(); // every tag slug any problem page carries -> tag
const tagged = new Map<string, number>(); // tag slug -> how many problem pages carry it
for (const sol of ordered) {
const langs = Object.keys(sol.code).sort().join("+");
const workRef = `${sol.num}.${sol.slug} (${langs})`;
// (0N-<phase>)/<topic>/ folder (the topic is a real route segment, so its
// index.mdx is the landing page) + leetcode-number prefix: numeric sidebar
// order, prefix stripped from the URL.
const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category };
// (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment,
// so its index.mdx is the landing page; `(problems)` adds none) +
// leetcode-number prefix: numeric sidebar order, prefix stripped from the URL.
const topic = topics.get(sol.num) ??
topicByName.get(sol.category) ?? { name: sol.category };
const dir = topicDir(topic);
groups.set(dir, topic);
if (topic.phase) phases.set(phaseDir(topic.phase), topic.phase);
const name = `${dir}/${sol.num}-${sol.slug}.mdx`;
const name = `${dir}/${sol.num}_${sol.slug}.mdx`;
const existing = pages.get(sol.num);
await mkdir(join(DOCS, dir), { recursive: true });
await mkdir(join(DOCS, dirname(name)), { recursive: true });
let written: string; // the page as it stands after this run, for its topics
if (existing) {
@@ -781,8 +813,12 @@ for (const sol of ordered) {
continue;
}
const tail = page.slice(solutionEnd(page, at));
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
const next = await ensureIssue(await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol), sol);
const body =
page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
const next = await ensureIssue(
await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol),
sol,
);
const actions: string[] = [];
if (existing !== name) {
await rename(join(DOCS, existing), join(DOCS, name));
@@ -796,17 +832,20 @@ for (const sol of ordered) {
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
written = next;
} else {
const page = renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol));
const page = renderPage(
sol,
await fetchTopicTags(sol),
await issueUrl(sol),
);
await Bun.write(join(DOCS, name), page);
report.push([workRef, name, "created"]);
written = page;
}
for (const tag of frontmatterTopics(written)) {
tags.set(tag.slug, tag);
tagged.set(tag.slug, (tagged.get(tag.slug) ?? 0) + 1);
}
for (const tag of frontmatterTopics(written)) tags.set(tag.slug, tag);
// The route drops the phase `(group)` and the page's number prefix.
redirects.push(` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`);
redirects.push(
` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`,
);
}
// Script-owned short links, one per page; imported by blume.config.ts.
@@ -819,54 +858,48 @@ for (const sol of ordered) {
"];",
"",
].join("\n");
if (!(await Bun.file(REDIRECTS).exists()) || (await Bun.file(REDIRECTS).text()) !== module) {
if (
!(await Bun.file(REDIRECTS).exists()) ||
(await Bun.file(REDIRECTS).text()) !== module
) {
await Bun.write(REDIRECTS, module);
report.push([`${redirects.length} redirects`, "apps/docs/redirects.ts", "written"]);
report.push([
`${redirects.length} redirects`,
"apps/docs/redirects.ts",
"written",
]);
}
}
// One guide page per tag that no campaign topic teaches, created when missing
// (an empty file counts as missing) and never rewritten. A guide is found by
// slug anywhere under appendix/ — a human files it into a category `(group)`,
// which leaves its route alone — and a new one lands at the appendix/ root. A
// guide whose tag no page carries any more is left alone: it is hand-owned.
const guideTags = [...tags.values()]
.filter((tag) => !TOPIC_LANDINGS[tag.slug])
.sort((a, b) => a.slug.localeCompare(b.slug));
const guideFiles = new Map<string, string>(); // slug -> path relative to DOCS
for await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel);
for (const tag of guideTags) {
const rel = guideFiles.get(tag.slug) ?? `appendix/${tag.slug}.mdx`;
const file = Bun.file(join(DOCS, rel));
if ((await file.exists()) && file.size > 0) continue;
await Bun.write(file, guidePage(tag));
report.push([`tag ${tag.slug}`, rel, "created"]);
// Every carried tag no campaign topic teaches is an Appendix term, filed by
// hand in apps/docs/appendix.ts: warn about the gaps and the dead weight. A
// term's icon is also the icon on every problem-page badge linking to its
// card, so it must not be a topic group's (`TOPIC_ICONS`), so a term badge
// never reads as a topic's, nor another term's.
for (const tag of [...tags.values()].sort((a, b) =>
a.slug.localeCompare(b.slug),
)) {
if (!TOPIC_LANDINGS[tag.slug] && !APPENDIX[tag.slug])
console.warn(
`tag ${tag.slug}: no Appendix term; add ${JSON.stringify(tag.name)} to apps/docs/appendix.ts`,
);
}
// Every guide's sidebar badge is its backlink count: the problem pages whose
// tag badge links to it, which is every page carrying the tag, since a guide's
// tag has no landing (`tagTarget()`). A guide whose tag a landing has since
// claimed has no backlinks left. Counts move with every solved problem, so the
// badge is rewritten whenever it differs; the rest of the guide is untouched.
// The same pass checks each guide's hand-picked `icon:`, which is also the icon
// on every problem-page badge linking to the guide: it must exist, must not be
// a topic group's (`TOPIC_ICONS`), so a guide badge never reads as a topic's,
// and must not be another guide's.
const topicIcons = new Set(Object.values(TOPIC_ICONS));
const guideIcons = new Map<string, string>(); // icon -> the guide that took it first
for await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) {
const slug = basename(rel, ".mdx");
const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0);
const page = await Bun.file(join(DOCS, rel)).text();
const icon = page.match(/^---\n[\s\S]*?\n---/)?.[0].match(/^icon: (\S+)$/m)?.[1];
if (!icon) console.warn(`${rel}: no icon; its badges render without one — file it and add \`icon:\``);
else if (topicIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is a topic's (TOPIC_ICONS); pick another`);
else if (guideIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is ${guideIcons.get(icon)}'s too; pick another`);
else guideIcons.set(icon, rel);
const next = withBacklinkBadge(page, count);
if (next === page) continue;
await Bun.write(join(DOCS, rel), next);
report.push([`${count} backlinks`, rel, "badged"]);
const termIcons = new Map<string, string>(); // icon -> the term that took it first
for (const [slug, term] of Object.entries(APPENDIX)) {
if (TOPIC_LANDINGS[slug])
console.warn(
`appendix ${slug}: a campaign topic teaches it now; delete its term`,
);
if (topicIcons.has(term.icon))
console.warn(
`appendix ${slug}: icon ${JSON.stringify(term.icon)} is a topic's (TOPIC_ICONS); pick another`,
);
else if (termIcons.has(term.icon))
console.warn(
`appendix ${slug}: icon ${JSON.stringify(term.icon)} is ${termIcons.get(term.icon)}'s too; pick another`,
);
else termIcons.set(term.icon, slug);
}
// Phase metas: created when missing (an empty file counts as missing), never
@@ -878,15 +911,15 @@ for (const [dir, phase] of phases) {
report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]);
}
// Script-owned folder metas — one per live topic folder, plus appendix/ —
// rewritten only when they differ.
const metas: [label: string, dir: string, meta: string][] = [
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
...(guideTags.length ? [["appendix", "appendix", APPENDIX_META] as [string, string, string]] : []),
];
// Script-owned folder metas — one per live topic folder — rewritten only when
// they differ.
const metas: [label: string, dir: string, meta: string][] = [...groups].map(
([dir, topic]) => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)],
);
for (const [label, dir, meta] of metas) {
const path = join(DOCS, dir, "meta.ts");
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta) continue;
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta)
continue;
await Bun.write(path, meta);
report.push([label, `${dir}/meta.ts`, "written"]);
}
@@ -914,4 +947,5 @@ const w0 = Math.max(...report.map((r) => r[0].length), 9);
const w1 = Math.max(...report.map((r) => r[1].length), 9);
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}`);
for (const [a, b, c] of report)
console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);
+232
View File
@@ -0,0 +1,232 @@
/**
* The Appendix glossary: one term per LeetCode topic tag that problem pages
* carry but no campaign topic teaches (not a key of `topic-landings.ts`).
* `pages/appendix.astro` renders each as a card — icon, category, definition,
* and the solved problems carrying the tag — anchored at `/appendix#<slug>`,
* which is where `tagTarget()` sends that tag's badge on a problem page.
*
* Hand-written: `apps/cli/sync.ts` only checks it, warning about a carried
* tag with no entry (its card renders bare), an entry whose tag a campaign
* topic now teaches (dead weight, delete it), and an icon that is a topic's
* (`TOPIC_ICONS`) or another term's — the icon is also the one on every
* badge linking here, so it must not read as anything else. Plain data, no
* Astro imports: sync imports this file under Bun.
*/
export type AppendixCategory = "Data Structures" | "Algorithms" | "Techniques";
export interface AppendixTerm {
/** LeetCode's tag name. */
name: string;
/** Lucide icon, unique across terms and topics. */
icon: string;
category: AppendixCategory;
/** One sentence: what the term means, the way a solution uses it. */
definition: string;
}
export const APPENDIX: Record<string, AppendixTerm> = {
// ── data structures ──────────────────────────────────────────────
array: {
name: "Array",
icon: "list-ordered",
category: "Data Structures",
definition: "A contiguous, index-addressed sequence: O(1) access by position, O(n) to insert or delete in the middle.",
},
"binary-tree": {
name: "Binary Tree",
icon: "git-branch",
category: "Data Structures",
definition: "A rooted tree in which every node has at most two children, left and right.",
},
"directed-acyclic-graph": {
name: "Directed Acyclic Graph",
icon: "route",
category: "Data Structures",
definition: "A directed graph with no cycles, which is exactly what lets its nodes be put in a topological order.",
},
"doubly-linked-list": {
name: "Doubly-Linked List",
icon: "link-2",
category: "Data Structures",
definition: "A linked list whose nodes point to both neighbours, so a node you already hold is unlinked in O(1).",
},
graph: {
name: "Graph Theory",
icon: "share-2",
category: "Data Structures",
definition: "Nodes joined by edges, directed or not, weighted or not, stored as an adjacency list or matrix.",
},
"k-d-tree": {
name: "K-D Tree",
icon: "axis-3d",
category: "Data Structures",
definition: "A binary tree that splits k-dimensional points on alternating axes, for nearest-neighbour and range queries.",
},
queue: {
name: "Queue",
icon: "list-end",
category: "Data Structures",
definition: "First in, first out: push at the back, pop from the front, both O(1).",
},
string: {
name: "String",
icon: "quote",
category: "Data Structures",
definition: "A sequence of characters; solutions lean on its small alphabet, its substrings, and its immutability.",
},
tree: {
name: "Tree",
icon: "tree-deciduous",
category: "Data Structures",
definition: "A connected acyclic graph, usually rooted, in which every node but the root has exactly one parent.",
},
// ── algorithms ───────────────────────────────────────────────────
"bidirectional-search": {
name: "Bidirectional Search",
icon: "move-horizontal",
category: "Algorithms",
definition: "Search from the start and the goal at once and stop where the frontiers meet, about 2·b^(d/2) states instead of b^d.",
},
"binary-lifting": {
name: "Binary Lifting",
icon: "chevrons-up",
category: "Algorithms",
definition: "Precompute every node's 2^k-th ancestor so any k-th ancestor, and so the LCA, takes O(log n) jumps.",
},
"bit-manipulation": {
name: "Bit Manipulation",
icon: "binary",
category: "Algorithms",
definition: "Treat integers as bit vectors: masks, shifts, and XOR in place of arithmetic or extra memory.",
},
"bracket-sequences": {
name: "Bracket Sequences",
icon: "parentheses",
category: "Algorithms",
definition: "Strings of opening and closing brackets; balanced means the running depth never dips below zero and ends at zero.",
},
"bucket-sort": {
name: "Bucket Sort",
icon: "container",
category: "Algorithms",
definition: "Scatter values into buckets by range or frequency, then read the buckets in order: linear when the key range is bounded.",
},
"complete-knapsack": {
name: "Complete Knapsack",
icon: "package-plus",
category: "Algorithms",
definition: "Knapsack in which every item may be taken any number of times, so the DP walks capacity upward and reuses the row.",
},
"divide-and-conquer": {
name: "Divide and Conquer",
icon: "split",
category: "Algorithms",
definition: "Split the input into independent parts, solve each recursively, and combine their answers.",
},
"floyds-cycle-finding-algorithm": {
name: "Floyd's Cycle Finding Algorithm",
icon: "repeat",
category: "Algorithms",
definition: "A slow and a fast pointer meet inside any cycle; restarting one from the head finds the cycle's entry, in O(1) space.",
},
"knapsack-problem": {
name: "Knapsack Problem",
icon: "backpack",
category: "Algorithms",
definition: "Pick items with weights and values to maximise value under a capacity; the 0/1 version is an O(n·W) DP.",
},
"longest-common-subsequence": {
name: "Longest Common Subsequence",
icon: "diff",
category: "Algorithms",
definition: "The longest sequence that appears in order, not necessarily contiguously, in two strings: the classic O(n·m) 2-D DP.",
},
"lowest-common-ancestor": {
name: "Lowest Common Ancestor",
icon: "git-merge",
category: "Algorithms",
definition: "The deepest node of a rooted tree that is an ancestor of both given nodes.",
},
memoization: {
name: "Memoization",
icon: "database",
category: "Algorithms",
definition: "Cache a pure function's result per argument so every subproblem is solved once: top-down dynamic programming.",
},
"merge-sort": {
name: "Merge Sort",
icon: "blend",
category: "Algorithms",
definition: "Sort each half recursively, then merge the two sorted halves: O(n log n), stable, O(n) extra space.",
},
quickselect: {
name: "Quickselect",
icon: "crosshair",
category: "Algorithms",
definition: "Partition around a pivot and recurse into one side only, finding the k-th smallest element in O(n) on average.",
},
quicksort: {
name: "Quicksort",
icon: "shuffle",
category: "Algorithms",
definition: "Partition around a pivot, then sort each side in place: O(n log n) on average, O(n²) at worst.",
},
recursion: {
name: "Recursion",
icon: "iteration-ccw",
category: "Algorithms",
definition: "Solve a problem by calling the same function on smaller inputs until a base case answers directly.",
},
simulation: {
name: "Simulation",
icon: "monitor-play",
category: "Algorithms",
definition: "Carry out the process the statement describes, step by step; the work is modelling the state faithfully.",
},
sorting: {
name: "Sorting",
icon: "arrow-down-wide-narrow",
category: "Algorithms",
definition: "Order the input first so a later step, such as two pointers, a greedy choice, or binary search, becomes possible.",
},
"tournament-sort": {
name: "Tournament Sort",
icon: "trophy",
category: "Algorithms",
definition: "A winner tree of pairwise comparisons: the root is the extreme element, and replaying one path yields the next in O(log n).",
},
// ── techniques ───────────────────────────────────────────────────
combinatorics: {
name: "Combinatorics",
icon: "dices",
category: "Techniques",
definition: "Count arrangements and selections (permutations, combinations, Catalan numbers), usually modulo a prime.",
},
counting: {
name: "Counting",
icon: "tally-5",
category: "Techniques",
definition: "Tally occurrences in a hash map or fixed array, then answer from the counts instead of the raw input.",
},
design: {
name: "Design",
icon: "pencil-ruler",
category: "Techniques",
definition: "Implement a class to an API spec, choosing the internals so every operation meets its time bound.",
},
geometry: {
name: "Geometry",
icon: "shapes",
category: "Techniques",
definition: "Points, lines, and areas on a plane or grid: distances, slopes, overlaps, and orientation by cross product.",
},
math: {
name: "Math",
icon: "calculator",
category: "Techniques",
definition: "An arithmetic or number-theory insight (modular arithmetic, GCD, digit manipulation, a closed form) in place of brute force.",
},
};
+24 -17
View File
@@ -1,6 +1,7 @@
import { defineConfig } from "blume";
import type { StandardSchema } from "blume/core/standard-schema.ts";
import { filesystem } from "blume/sources";
import { APPENDIX } from "./appendix";
import redirects from "./redirects";
// Blume's defaults (`inter`, `ibm-plex-mono`) are fetched from fonts.gstatic.com
@@ -36,10 +37,9 @@ const plexMono = {
* components/ProblemBadges.astro. Problem pages: `topics:` is LeetCode's topic
* tags and `issue:` the problem's GitHub issue URL (both written by
* apps/cli/sync.ts), `video:` the NeetCode walkthrough's
* YouTube id (apps/cli/videos.ts). Guide pages (content/appendix/, created by
* sync): `list:` is the tag's LeetCode problem list. Blume rejects undeclared
* keys and takes any Standard Schema; these are hand-rolled so the docs
* workspace needs no zod. All optional — every other page carries none.
* YouTube id (apps/cli/videos.ts). Blume rejects undeclared keys and takes
* any Standard Schema; these are hand-rolled so the docs workspace needs no
* zod. All optional — every other page carries none.
*/
const optional = <T>(
message: string,
@@ -68,12 +68,6 @@ const videoSchema = optional(
(value): value is string =>
typeof value === "string" && /^[\w-]{11}$/.test(value),
);
const listSchema = optional(
"expected a https://leetcode.com/problem-list/<slug>/ URL",
(value): value is string =>
typeof value === "string" &&
/^https:\/\/leetcode\.com\/problem-list\/[\w-]+\/$/.test(value),
);
const issueSchema = optional(
"expected a https://github.com/<owner>/<repo>/issues/<n> URL",
(value): value is string =>
@@ -93,12 +87,20 @@ export default defineConfig({
repo: "leetcode",
},
lastModified: "git",
redirects,
// Sync's `/<num>` short links, then the Appendix's former per-term pages
// (`/appendix/<slug>`, published until the glossary became one page) onto
// their cards, so old links and search results still land.
redirects: [
...redirects,
...Object.keys(APPENDIX).map((slug) => ({
from: `/appendix/${slug}`,
to: `/appendix#${slug}`,
})),
],
frontmatter: {
extend: {
topics: topicsSchema,
video: videoSchema,
list: listSchema,
issue: issueSchema,
},
},
@@ -125,9 +127,10 @@ export default defineConfig({
llmsTxt: true,
mcp: { enabled: false },
},
// The only content source. `/changelog` is a custom page (pages/
// changelog.astro) reading the GitHub releases itself — one timeline, no
// per-release pages, so nothing of it enters the sidebar or prev/next.
// The only content source. `/changelog` and `/appendix` are custom pages
// (pages/*.astro): `/changelog` reads the GitHub releases itself — one
// timeline, no per-release pages — and `/appendix` is one glossary page
// built from appendix.ts, so neither enters the sidebar tree or prev/next.
content: {
sources: [filesystem({ root: "content" })],
},
@@ -139,7 +142,11 @@ export default defineConfig({
sidebar: {
display: "flat", // "flat" | "group" | "page"
},
// A plain header link on the right, not a tab.
actions: [{ href: "/changelog", label: "Changelog" }],
// The glossary is no content folder any more, so it is pinned above the
// sidebar tree, on every page and breakpoint.
featured: [
{ label: "Changelog", href: "/changelog", icon: "clock" },
{ label: "Appendix", href: "/appendix", icon: "library" },
],
},
});
+1 -1
View File
@@ -14,7 +14,7 @@ export default defineComponents({
wrapper: "./components/ProblemBadges.astro",
},
layout: {
// Topic landing + guide pages' "what links here": every problem pointing at the page.
// Topic landing pages' "what links here": every problem pointing at the page.
PageFooter: "./components/BacklinksList.astro",
},
});
+4 -3
View File
@@ -1,9 +1,10 @@
---
/**
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
* or guide page — every problem page that points at it, as `<num> <name>`
* rows in LeetCode-number order. Pages nothing points at (problem pages, the
* overview, the changelog) render nothing.
* page — every problem page that points at it, as `<num> <name>` rows in
* LeetCode-number order. Pages nothing points at (problem pages, the
* overview) render nothing; a tag no topic teaches lists its problems on its
* Appendix card instead (`pages/appendix.astro`), from the same answer.
*
* A problem points at a page when either
* - one of its topic badges links there — the same `tagTarget()` answer
+16 -28
View File
@@ -11,28 +11,27 @@
*
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
* topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that
* topic's landing page and is filled; any other tag links to its guide
* page in the Appendix, `/appendix/<slug>` (sync creates one per such tag), and is outlined.
* topic's landing page and is filled; any other tag links to its card on the
* Appendix glossary, `/appendix#<slug>` (`appendix.ts`), and is outlined.
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
* `PageFooter` backlinks list reads the same answer. Each tag badge is a
* pill carrying the icon its target shows in the sidebar (the topic's
* `meta.ts` icon, the guide's `icon:`) and the tag's own colour
* (`lib/tag-colors.ts`), assigned over every tag the site has a page for —
* no two tags share one, and tags that share problem pages are spread
* furthest apart. A tag with no page (the LeetCode-list fallback) stays
* gray and iconless.
* `PageFooter` backlinks list and the Appendix page read the same answer.
* Each tag badge is a pill carrying the icon its target shows (a topic's
* `meta.ts` icon in the sidebar, a term's `icon` on its card) and the tag's
* own colour (`lib/tag-colors.ts`), assigned over every tag the site has a
* page or card for — no two tags share one, and tags that share problem
* pages are spread furthest apart. A tag with neither (the LeetCode-list
* fallback) stays gray and iconless.
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
* walkthrough's YouTube id, the red "Explanation" badge.
* - `issue:` (problem pages, written by sync) is the problem's GitHub issue
* URL, a neutral "Issue #N" badge with the GitHub mark, right after
* "Explanation" at the end of the row.
* - `list:` (guide pages, written by sync on create) is the tag's LeetCode
* problem list, an orange "LeetCode list" badge.
*/
import { getEntry } from "astro:content";
import data from "blume:data";
import Badge from "blume/components/content/Badge.astro";
import { stripBase, withBase } from "blume/components/islands/base-path.ts";
import { APPENDIX } from "../appendix.ts";
import { tagColors } from "../lib/tag-colors.ts";
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
@@ -46,7 +45,6 @@ const at = data.routes.find((r: { path: string }) => r.path === route);
const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
const tags: TopicTag[] = entry?.data.topics ?? [];
const video: string | undefined = entry?.data.video;
const list: string | undefined = entry?.data.list;
const issue: string | undefined = entry?.data.issue;
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
@@ -67,15 +65,12 @@ const walk = (nodes: NavNode[]) => {
};
walk(data.navigation.sidebar);
// Every tag with a page — the ones a landing teaches, plus every served guide —
// coloured against how problem pages (`<num>. <name>` titles, the shape sync
// writes) combine them. Only a page with tags needs the answer.
// Every tag with a landing or an Appendix card, coloured against how problem
// pages (`<num>. <name>` titles, the shape sync writes) combine them. Only a
// page with tags needs the answer.
const colors = tags.length
? tagColors(
[
...Object.keys(TOPIC_LANDINGS),
...[...served].flatMap((path) => path.match(/^\/appendix\/([^/]+)$/u)?.slice(1) ?? []),
],
[...Object.keys(TOPIC_LANDINGS), ...Object.keys(APPENDIX)],
await Promise.all(
data.routes
.filter((r: { title: string }) => /^\d+\.\s/u.test(r.title))
@@ -94,7 +89,7 @@ const badges = tags.map((tag) => {
name: tag.name,
href: external ? target.href : withBase(target.href),
external,
icon: external ? undefined : icons.get(target.href),
icon: target.kind === "appendix" ? APPENDIX[tag.slug]?.icon : external ? undefined : icons.get(target.href),
color: external ? undefined : colors.get(tag.slug),
stroke: target.kind !== "landing",
};
@@ -104,7 +99,7 @@ const linkClass = "no-underline transition-opacity hover:opacity-80";
---
{
(badges.length > 0 || video || list || issue) && (
(badges.length > 0 || video || issue) && (
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
{badges.map((b) => (
<a
@@ -132,13 +127,6 @@ const linkClass = "no-underline transition-opacity hover:opacity-80";
</Badge>
</a>
)}
{list && (
<a href={list} target="_blank" rel="noopener" class={linkClass}>
<Badge variant="warning" icon="list" shape="pill">
LeetCode list
</Badge>
</a>
)}
</div>
)
}
@@ -0,0 +1,48 @@
---
title: '162. Find Peak Element'
description: A peak element is an element that is strictly greater than its neighbors
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
issue: "https://github.com/prdlk/leetcode/issues/64"
sidebar:
label: 'Find Peak Element'
badge: 'Medium'
---
::::warning
You must write an algorithm that runs in O(log n) time.
::::
### Example 1:
- Input: `nums = [1,2,3,1]`
- Output: `2`
- Explanation: `3` is a peak element and your function should return the index number `2`.
### Example 2:
- Input: `nums = [1,2,1,3,5,6,4]`
- Output: `5`
- Explanation: Your function can return either index number `1` where the peak element is `2`, or index number `5` where the peak element is `6`.
### Constraints:
- `1 <= nums.length <= 1000`
- `-2^31 <= nums[i] <= 2^31 - 1`
- `nums[i] != nums[i + 1] for all valid i.`
## Solution
```py
class Solution:
def findPeakElement(self, nums: list[int]) -> int:
left, right = 0, len(nums) - 1
while left < right:
mid = (left + right) // 2
if nums[mid] < nums[mid + 1]:
left = mid + 1
else:
right = mid
return left
```
@@ -0,0 +1,63 @@
---
title: '4. Median of Two Sorted Arrays'
description: Given two sorted arrays nums1 and nums2 of size m and n respectively, return the median of the two sorted arrays
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
- { name: "Divide and Conquer", slug: "divide-and-conquer" }
issue: "https://github.com/prdlk/leetcode/issues/66"
sidebar:
label: 'Median of Two Sorted Arrays'
badge: 'Hard'
---
### Example 1:
- Input: `nums1 = [1,3], nums2 = [2]`
- Output: `2.00000`
- Explanation: merged array = `[1,2,3]` and median is `2`.
### Example 2:
- Input: `nums1 = [1,2], nums2 = [3,4]`
- Output: `2.50000`
- Explanation: merged array = `[1,2,3,4]` and median is `(2 + 3`) / `2 = 2.5`.
### Constraints:
- `nums1.length == m`
- `nums2.length == n`
- `0 <= m <= 1000`
- `0 <= n <= 1000`
- `1 <= m + n <= 2000`
- `-10^6 <= nums1[i], nums2[i] <= 10^6`
## Solution
```py
class Solution:
def findMedianSortedArrays(self, nums1: list[int], nums2: list[int]) -> float:
if len(nums1) > len(nums2):
return self.findMedianSortedArrays(nums2, nums1)
m, n = len(nums1), len(nums2)
left, right = 0, m
while left <= right:
partA = (left + right) // 2
partB = (m + n + 1) // 2 - partA
maxLeftA = float("-inf") if partA == 0 else nums1[partA - 1]
minRightA = float("inf") if partA == m else nums1[partA]
maxLeftB = float("-inf") if partB == 0 else nums2[partB - 1]
minRightB = float("inf") if partB == n else nums2[partB]
if maxLeftA <= minRightB and maxLeftB <= minRightA:
if (m + n) % 2 == 0:
return (max(maxLeftA, maxLeftB) + min(minRightA, minRightB)) / 2
else:
return max(maxLeftA, maxLeftB)
elif maxLeftA > minRightB:
right = partA - 1
else:
left = partA + 1
```
@@ -0,0 +1,56 @@
---
title: '128. Longest Consecutive Sequence'
description: Given an unsorted array of integers nums, return the length of the longest consecutive elements sequence
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "Union-Find", slug: "union-find" }
issue: "https://github.com/prdlk/leetcode/issues/34"
sidebar:
label: 'Longest Consecutive Sequence'
badge: 'Medium'
---
::::warning
You must write an algorithm that runs in O(n) time.
::::
### Example 1:
- Input: `nums = [100,4,200,1,3,2]`
- Output: `4`
- Explanation: The longest consecutive elements sequence is [1, `2`, `3`, 4]. Therefore its length is `4`.
### Example 2:
- Input: `nums = [0,3,7,2,5,8,4,6,0,1]`
- Output: `9`
### Example 3:
- Input: `nums = [1,0,1,2]`
- Output: `3`
### Constraints:
- `0 <= nums.length <= 10^5`
- `-10^9 <= nums[i] <= 10^9`
## Solution
```py
class Solution:
def longestConsecutive(self, nums: list[int]) -> int:
seen = set(nums)
best = 0
for n in seen:
if n - 1 in seen:
continue
length = 1
while length + n in seen:
length += 1
best = max(length, best)
return best
```
@@ -0,0 +1,57 @@
---
title: '290. Word Pattern'
description: Given a pattern and a string s, find if s follows the same pattern
icon: dot
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
issue: "https://github.com/prdlk/leetcode/issues/35"
sidebar:
label: 'Word Pattern'
badge: 'Easy'
---
### Example 1:
- Input: `pattern = "abba", s = "dog cat cat dog"`
- Output: `true`
- Explanation: The bijection can be established as: • 'a' maps to "dog". • 'b' maps to "cat".
### Example 2:
- Input: `pattern = "abba", s = "dog cat cat fish"`
- Output: `false`
### Example 3:
- Input: `pattern = "aaaa", s = "dog cat cat dog"`
- Output: `false`
### Constraints:
- `1 <= pattern.length <= 300`
- `pattern` contains only lower-case English letters.
- `1 <= s.length <= 3000`
- `s` contains only lowercase English letters and spaces ' '.
- `s` does not contain any leading or trailing spaces.
- All the words in `s` are separated by a single space.
## Solution
```py
class Solution:
def wordPattern(self, pattern: str, s: str) -> bool:
if len(pattern) != len(s.split()):
return False
words = s.split()
map = {}
for i in range(len(pattern)):
c = pattern[i]
w = words[i]
if c in map and map[c] != w:
return False
else:
map[c] = w
return len(set(pattern)) == len(set(words))
```
@@ -4,8 +4,6 @@ description: 'A key maps to a value in constant time, which removes the need for
hidden: true
---
## Hash Tables
Sometimes when programming, we need a data structure that maps an arbitrary data type to another arbitrary data type, like an array with non-integer indices. For example, a phone book that allows lookup by a person's name requires such data type.
A **hash table** (or a hash map) is one way of representing this data structure. The idea of hash table is this:
@@ -14,7 +12,9 @@ Data is stored in a fix-sized array, where each data value has an unique index.
<Frame caption="Hash Table Operations">
<img src="../../../public/hash-table-operations.svg" alt="Hash Table Operations" />
![](../../../public/hash-table-operations.svg)
</Frame>
@@ -22,7 +22,7 @@ Data is stored in a fix-sized array, where each data value has an unique index.
Assuming the hash function uses constant time, the average time complexity is `O(n / k)` for each insertion/access operation on the table, where `n` is the number of entries in the hash table and `k` is the array size of the table. This assumes the hash function distributes keys uniformly across the `k` buckets, so each bucket holds about `n / k` entries on average.
**Frequency Counting**
### Frequency Counting
```python
def get_counter(arr: list[int]) -> dict[int, int]:
@@ -39,10 +39,12 @@ def get_counter(arr: list[int]) -> dict[int, int]:
return counter
```
::: tip[Python provides a built-in `collections.Counter` class]
:::tip
Python provides a built-in [`collections.Counter`](https://docs.python.org/3/library/collections.html#collections.Counter) class that does this for us.
:::
```python
from collections import Counter
fq = Counter(arr)
```
:::
@@ -0,0 +1,51 @@
---
title: '84. Largest Rectangle in Histogram'
description: Given an array of integers heights representing the histogram's bar height where the width of each bar is 1, return the area of the largest rectangle in the histogram
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Stack", slug: "stack" }
- { name: "Monotonic Stack", slug: "monotonic-stack" }
- { name: "Range Minimum/Maximum Query", slug: "range-minimum-maximum-query" }
issue: "https://github.com/prdlk/leetcode/issues/76"
sidebar:
label: 'Largest Rectangle in Histogram'
badge: 'Hard'
---
### Example 1:
- Input: `heights = [2,1,5,6,2,3]`
- Output: `10`
- Explanation: The above is a histogram where width of each bar is `1`. The largest rectangle is shown in the red area, which has an area = `10` units.
### Example 2:
- Input: `heights = [2,4]`
- Output: `4`
### Constraints:
- `1 <= heights.length <= 10^5`
- `0 <= heights[i] <= 10^4`
## Solution
```py
class Solution:
def largestRectangleArea(self, heights: list[int]) -> int:
maxArea = 0
stack = []
for i, h in enumerate(heights):
start = i
while stack and stack[-1][1] > h:
index, height = stack.pop()
width = i - index
maxArea = max(maxArea, height * width)
start = index
stack.append((start, h))
for i, h in stack:
maxArea = max(maxArea, h * (len(heights) - i))
return maxArea
```
@@ -0,0 +1,53 @@
---
title: '525. Contiguous Array'
description: Given a binary array nums, return the maximum length of a contiguous subarray with an equal number of 0 and 1
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "Prefix Sum", slug: "prefix-sum" }
issue: "https://github.com/prdlk/leetcode/issues/39"
sidebar:
label: 'Contiguous Array'
badge: 'Medium'
---
### Example 1:
- Input: `nums = [0,1]`
- Output: `2`
- Explanation: [0, 1] is the longest contiguous subarray with an equal number of `0` and `1`.
### Example 2:
- Input: `nums = [0,1,0]`
- Output: `2`
- Explanation: [0, 1] (or [1, 0]) is a longest contiguous subarray with equal number of `0` and `1`.
### Example 3:
- Input: `nums = [0,1,1,1,1,1,0,0,0]`
- Output: `6`
- Explanation: `[1,1,1,0,0,0]` is the longest contiguous subarray with equal number of `0` and `1`.
### Constraints:
- `1 <= nums.length <= 10^5`
- `nums[i]` is either 0 or 1.
## Solution
```py
class Solution:
def findMaxLength(self, nums: List[int]) -> int:
map = {0: -1}
count = 0
longest = 0
for i, num in enumerate(nums):
count += 1 if num == 1 else -1
if count in map:
longest = max(longest, i - map[count])
else:
map[count] = i
return longest
```
@@ -0,0 +1,52 @@
---
title: '1004. Max Consecutive Ones III'
description: Given a binary array nums and an integer k, return the maximum number of consecutive 1's in the array if you can flip at most k 0's
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
- { name: "Sliding Window", slug: "sliding-window" }
- { name: "Prefix Sum", slug: "prefix-sum" }
issue: "https://github.com/prdlk/leetcode/issues/56"
sidebar:
label: 'Max Consecutive Ones III'
badge: 'Medium'
---
### Example 1:
- Input: `nums = [1,1,1,0,0,0,1,1,1,1,0], k = 2`
- Output: `6`
- Explanation: `[1,1,1,0,0,1,1,1,1,1,1]` Bolded numbers were flipped from `0` to `1`. The longest subarray is underlined.
### Example 2:
- Input: `nums = [0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1], k = 3`
- Output: `10`
- Explanation: `[0,0,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1]` Bolded numbers were flipped from `0` to `1`. The longest subarray is underlined.
### Constraints:
- `1 <= nums.length <= 10^5`
- `nums[i]` is either 0 or 1.
- `0 <= k <= nums.length`
## Solution
```py
class Solution:
def longestOnes(self, nums: list[int], k: int) -> int:
left = 0
cur = 0
ans = 0
for right in range(len(nums)):
if nums[right] == 0:
cur += 1
while cur > k:
if nums[left] == 0:
cur -= 1
left += 1
ans = max(ans, right - left + 1)
return ans
```
@@ -0,0 +1,46 @@
---
title: '643. Maximum Average Subarray I'
description: You are given an integer array nums consisting of n elements, and an integer k
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Sliding Window", slug: "sliding-window" }
issue: "https://github.com/prdlk/leetcode/issues/55"
sidebar:
label: 'Maximum Average Subarray I'
badge: 'Easy'
---
### Example 1:
- Input: `nums = [1,12,-5,-6,50,3], k = 4`
- Output: `12.75000`
- Explanation: Maximum average is `(12 - 5 - 6 + 50`) / `4 = 51 / 4 = 12.75`
### Example 2:
- Input: `nums = [5], k = 1`
- Output: `5.00000`
### Constraints:
- `n == nums.length`
- `1 <= k <= n <= 10^5`
- `-10^4 <= nums[i] <= 10^4`
## Solution
```py
class Solution:
def findMaxAverage(self, nums: list[int], k: int) -> float:
window_sum = 0
for i in range(k):
window_sum += nums[i]
largest = window_sum
for right in range(k, len(nums)):
left = right - k
window_sum -= nums[left]
window_sum += nums[right]
largest = max(largest, window_sum)
return largest / k
```
@@ -4,6 +4,10 @@ description: 'Sliding window is a specialized two-pointer family where the point
hidden: true
---
<YouTube id="tSTO9w3piAo" title="Sliding Window" />
Many problems have two moving boundaries. That alone does not make them sliding window problems.
A problem belongs to this family when:
@@ -44,18 +48,14 @@ The window size never changes. The main task is updating the window state effici
```python
def fixed_window(nums, k):
window_sum = 0
best = float('-inf')
for right in range(len(nums)):
window_sum += nums[right] # add the new element
if right >= k - 1: # window is full
best = max(best, window_sum) # record answer
window_sum -= nums[right - k + 1] # remove the oldest element
return best
def sliding_window_fixed(input, window_size):
ans = window = input[0:window_size]
for right in range(window_size, len(input)):
left = right - window_size
remove input[left] from window
append input[right] to window
ans = optimal(ans, window)
return ans
```
<Steps>
@@ -83,24 +83,43 @@ Here the window expands until invalid, then shrinks until valid again.
</Frame>
<Tabs inline>
<Tab title="Longest Window">
```python
def flexible_window(nums, target):
def sliding_window_flexible_longest(input):
initialize window, ans
left = 0
window_sum = 0
best = 0
for right in range(len(nums)):
window_sum += nums[right] # grow the window
while window_sum > target: # window is invalid
window_sum -= nums[left] # shrink from the left
for right in range(len(input)):
append input[right] to window
while invalid(window): # update left until window is valid again
remove input[left] from window
left += 1
ans = max(ans, window) # window is guaranteed to be valid here
return ans
best = max(best, right - left + 1) # window is valid, record answer
return best
```
</Tab>
<Tab title="Shortest Window">
```python
def sliding_window_flexible_shortest(input):
initialize window, ans
left = 0
for right in range(len(input)):
append input[right] to window
while valid(window):
ans = min(ans, window) # window is guaranteed to be valid here
remove input[left] from window
left += 1
return ans
```
</Tab>
</Tabs>
<Steps>
<Step title="">
Add `nums[right]` to the window.
@@ -115,26 +134,6 @@ def flexible_window(nums, target):
---
For "shortest window" problems, swap the logic: use `while` for the *valid* case, record the answer inside the loop, then shrink.
```python
def shortest_valid_window(nums, target):
left = 0
window_sum = 0
best = float('inf')
for right in range(len(nums)):
window_sum += nums[right]
while window_sum >= target: # window is valid
best = min(best, right - left + 1) # record, then try to shrink
window_sum -= nums[left]
left += 1
return best if best != float('inf') else 0
```
## 3. Frequency-tracking windows
:::tip
@@ -0,0 +1,62 @@
---
title: '71. Simplify Path'
description: You are given an absolute path for a Unix-style file system, which always begins with a slash '/'. Your task is to transform this absolute path into its simplified canonical path
icon: dot
topics:
- { name: "String", slug: "string" }
- { name: "Stack", slug: "stack" }
issue: "https://github.com/prdlk/leetcode/issues/70"
sidebar:
label: 'Simplify Path'
badge: 'Medium'
---
### Example 1:
- Input: `path = "/home/"`
- Output: `"/home"`
- Explanation: The trailing slash should be removed.
### Example 2:
- Input: `path = "/home//foo/"`
- Output: `"/home/foo"`
- Explanation: Multiple consecutive slashes are replaced by a single one.
### Example 3:
- Input: `path = "/home/user/Documents/../Pictures"`
- Output: `"/home/user/Pictures"`
- Explanation: A double period ".." refers to the directory up a level (the parent directory).
### Example 4:
- Input: `path = "/../"`
- Output: `"/"`
- Explanation: Going one level up from the root directory is not possible.
### Example 5:
- Input: `path = "/.../a/../b/c/../d/./"`
- Output: `"/.../b/d"`
- Explanation: "..." is a valid name for a directory in this problem.
### Constraints:
- `1 <= path.length <= 3000`
- `path` consists of English letters, digits, period '.', slash '/' or '_'.
- `path` is a valid absolute Unix path.
## Solution
```py
class Solution:
def simplifyPath(self, path: str) -> str:
stack = []
segments = [item for item in path.split("/") if item]
for p in segments:
if stack and p == "..":
stack.pop()
elif not stack and p == ".." or p == ".":
continue
else:
stack.append(p)
return "/" + "/".join(stack)
```
@@ -0,0 +1,50 @@
---
title: '189. Rotate Array'
description: Given an integer array nums, rotate the array to the right by k steps, where k is non-negative
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Math", slug: "math" }
- { name: "Two Pointers", slug: "two-pointers" }
issue: "https://github.com/prdlk/leetcode/issues/47"
sidebar:
label: 'Rotate Array'
badge: 'Medium'
---
### Example 1:
- Input: `nums = [1,2,3,4,5,6,7], k = 3`
- Output: `[5,6,7,1,2,3,4]`
- Explanation: rotate `1` steps to the right: `[7,1,2,3,4,5,6]` rotate `2` steps to the right: `[6,7,1,2,3,4,5]` rotate `3` steps to the right: `[5,6,7,1,2,3,4]`
### Example 2:
- Input: `nums = [-1,-100,3,99], k = 2`
- Output: `[3,99,-1,-100]`
- Explanation: rotate `1` steps to the right: `[99,-1,-100,3]` rotate `2` steps to the right: `[3,99,-1,-100]`
### Constraints:
- `1 <= nums.length <= 10^5`
- `-2^31 <= nums[i] <= 2^31 - 1`
- `0 <= k <= 10^5`
- Try to come up with as many solutions as you can. There are at least three different ways to solve this problem.
- Could you do it in-place with O(1) extra space?
## Solution
```py
class Solution:
def rotate(self, nums: list[int], k: int) -> None:
N = len(nums) - 1
cut = k % len(nums)
def reverse(l, r):
while l <= r:
nums[l], nums[r] = nums[r], nums[l]
l += 1
r -= 1
reverse(0, N)
reverse(0, cut - 1)
reverse(cut, N)
```
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more