mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-03 13:27:36 +00:00
Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66affbe128 | ||
|
|
9de25fecff | ||
|
|
d388b3000e | ||
|
|
f14dcf2723 | ||
|
|
d171fe1565 | ||
|
|
e9d40020f4 | ||
|
|
0b004fcba2 | ||
|
|
33022c806d | ||
|
|
d59b9dccd9 | ||
|
|
17c691c481 | ||
|
|
ea62cda955 | ||
|
|
74939c31d7 | ||
|
|
0d5c40ac4a | ||
|
|
4a1838ab28 | ||
|
|
c2a375fffc | ||
|
|
0e9e06eb8d | ||
|
|
937266b629 | ||
|
|
88c0c9b898 | ||
|
|
099123539e | ||
|
|
8c39b3ac23 | ||
|
|
7ead021ee2 | ||
|
|
0d066b8104 | ||
|
|
9af5ab1fbb | ||
|
|
f770408ea2 | ||
|
|
cf6f09825a | ||
|
|
ab09e40a2e | ||
|
|
91dad73ebf | ||
|
|
400bd5abf5 | ||
|
|
a258b46cf0 | ||
|
|
4875fd5139 | ||
|
|
7b14673e95 | ||
|
|
1c45e03ddd | ||
|
|
cbc858b667 | ||
|
|
c890fd35a7 | ||
|
|
3e62d6fa6f | ||
|
|
017f111594 | ||
|
|
9899813281 | ||
|
|
a19fd95cc4 | ||
|
|
799dc2bb80 | ||
|
|
978589b53e | ||
|
|
876868f2a4 | ||
|
|
16317c8645 | ||
|
|
fdd54e4a1c | ||
|
|
24ef28d8b1 | ||
|
|
b461d9cab7 | ||
|
|
fd57f53d34 | ||
|
|
4b8496c113 | ||
|
|
7f6a7d4776 | ||
|
|
45bd3f771d | ||
|
|
c3c482d954 | ||
|
|
65f5965a12 | ||
|
|
a20be5182f | ||
|
|
c05e7f0ef2 | ||
|
|
76c2a64fc6 | ||
|
|
eb1002727b | ||
|
|
41c8c2036e | ||
|
|
31054c01a7 | ||
|
|
239c7013f8 | ||
|
|
7e9ea8d7c2 | ||
|
|
4548289ecf | ||
|
|
d6b483f84e | ||
|
|
a64a6c2379 | ||
|
|
ffa8a11f51 | ||
|
|
d722feda1c | ||
|
|
9c7d48aad8 | ||
|
|
1d77c5a8b5 | ||
|
|
b2dffa4615 | ||
|
|
f8ff12b55e | ||
|
|
c791974271 |
No files matched your search
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: problem-approach
|
name: problem-approach
|
||||||
description: Write the `## Approach` section — a Mermaid flowchart that traces the submitted solution — for every docs solution page under apps/docs/content/ that lacks one, using the repo's exact placement and node conventions. Use when asked to add/implement/fill in missing approaches, draw the approach diagram for a problem page, or when `/problem-approach` is run.
|
description: Write the `## Approach` section — a Mermaid flowchart that traces the submitted solution — for every docs solution page under docs/ that lacks one, using the repo's exact placement and node conventions. Use when asked to add/implement/fill in missing approaches, draw the approach diagram for a problem page, or when `/problem-approach` is run.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Problem approach diagrams
|
# Problem approach diagrams
|
||||||
|
|
||||||
Every solution page (`apps/docs/content/(<phase>)/<topic>/<num>-<slug>.mdx`) has a
|
Every solution page (`docs/(<phase>)/<topic>/(problems)/<num>_<slug>.mdx`) has a
|
||||||
hand-owned section before the script-owned one:
|
hand-owned section before the script-owned one:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -23,7 +23,7 @@ next `work/` push.
|
|||||||
1. **Find the pages** (numbered pages with no `## Approach` heading):
|
1. **Find the pages** (numbered pages with no `## Approach` heading):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
grep -L '^## Approach' apps/docs/content/*/*.mdx
|
grep -rL '^## Approach' docs --include='[0-9]*_*.mdx'
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **For each page**, read `## Solution` and write the diagram from *that code* — the
|
2. **For each page**, read `## Solution` and write the diagram from *that code* — the
|
||||||
@@ -47,11 +47,13 @@ next `work/` push.
|
|||||||
````
|
````
|
||||||
|
|
||||||
4. **Check** each diagram by hand against the syntax rules below (the docs build does
|
4. **Check** each diagram by hand against the syntax rules below (the docs build does
|
||||||
not parse Mermaid — a broken diagram fails only in the browser), then run the
|
not parse Mermaid — a broken diagram fails only in the browser). The site is built
|
||||||
docs build once at the end and review the pages in `bun run dev`:
|
from prdlk/cs, which reads `docs/` over HTTP, so preview unpushed pages there
|
||||||
|
against this checkout (its `README` names the local-content switch), then build
|
||||||
|
once at the end:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd apps/docs && bunx blume build --isolated && rm -rf .blume-verify
|
cd ../cs && bunx blume build --isolated && rm -rf .blume-verify
|
||||||
```
|
```
|
||||||
|
|
||||||
Pages with two languages (`<CodeGroup>`) get **one** diagram, traced from the
|
Pages with two languages (`<CodeGroup>`) get **one** diagram, traced from the
|
||||||
@@ -59,7 +61,7 @@ Python version. Pages with only JavaScript trace the JavaScript.
|
|||||||
|
|
||||||
## Diagram conventions
|
## Diagram conventions
|
||||||
|
|
||||||
Reference page: `apps/docs/content/(array)/15-3sum.mdx`. Every existing diagram
|
Reference page: `docs/(01-linear-structures)/two-pointers/(problems)/15_3sum.mdx`. Every existing diagram
|
||||||
follows this shape:
|
follows this shape:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -100,7 +102,7 @@ flowchart TD
|
|||||||
The base case is the first decision after entry.
|
The base case is the first decision after entry.
|
||||||
- **Classes** (design problems) use one `subgraph name["method(args)"]` per public
|
- **Classes** (design problems) use one `subgraph name["method(args)"]` per public
|
||||||
method, plus a `state["…"]` subgraph describing the fields, with `state --> method`
|
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.
|
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
|
- **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
|
per diagram): `"nums.sort() — duplicates become adjacent"`, `"map = {0: 1} — the
|
||||||
@@ -122,7 +124,7 @@ flowchart TD
|
|||||||
### Code that does not run
|
### Code that does not run
|
||||||
|
|
||||||
Trace it anyway — the diagram documents what was submitted — and put a `::::warning`
|
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
|
````md
|
||||||
## Approach
|
## Approach
|
||||||
|
|||||||
@@ -62,13 +62,17 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
bun apps/cli/sync-project-fields.ts
|
bun apps/cli/sync-project-fields.ts
|
||||||
|
|
||||||
# One timever release per finished topic (every core sub-issue closed),
|
# One timever release per finished topic (every core sub-issue closed) and
|
||||||
# tagged at the instant the last core problem closed. It reads the same rule
|
# per finished `Review <N> — <Phase>` issue (every sub-issue closed), tagged
|
||||||
|
# at the instant the last required problem closed. It reads the same rules
|
||||||
# close-topics uses (apps/cli/topics.ts) straight off the sub-issues, so the
|
# close-topics uses (apps/cli/topics.ts) straight off the sub-issues, so the
|
||||||
# push that finishes a topic releases it in this run — no waiting for Close
|
# push that finishes a topic or review releases it in this run — no waiting
|
||||||
# Topics, which chains after this workflow. Reconciling: a run releases
|
# for Close Topics, which chains after this workflow. A problem re-parented
|
||||||
# every finished topic still missing one, so a manual dispatch backfills and
|
# into a Review issue still belongs to its topic (apps/api/src/reviews.ts),
|
||||||
# a problem closed by hand in the UI is picked up by the next run.
|
# so moving it never rewrites a topic's release. Reconciling: a run releases
|
||||||
|
# everything finished and still missing a release, so a manual dispatch
|
||||||
|
# backfills and a problem closed by hand in the UI is picked up by the next
|
||||||
|
# run. The docs changelog (deploy.yml) tags each release Topic or Review.
|
||||||
release:
|
release:
|
||||||
needs: close
|
needs: close
|
||||||
# Like close-topics: a partially failed close still closed some issues,
|
# Like close-topics: a partially failed close still closed some issues,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
name: Close Topics
|
name: Close Topics
|
||||||
|
|
||||||
# No D1 push here, unlike Close Solved: D1 stores no topic completion column.
|
# Closes finished topics AND finished `Review <N> — <Phase>` issues (every
|
||||||
# Phase progress, the gate pools and the charts all derive from the problem
|
# sub-issue closed). Problems moved from a topic into a Review issue still
|
||||||
# rows, which close-solved already logs — a topic is finished exactly when its
|
# count toward that topic (apps/cli/topics.ts).
|
||||||
# core problems are, so there is nothing left to reconcile.
|
#
|
||||||
|
# No D1 push here, unlike Close Solved: D1 stores no topic or review
|
||||||
|
# completion column. Phase progress, the gate pools and the charts all derive
|
||||||
|
# from the problem rows, which close-solved already logs — a topic or review
|
||||||
|
# is finished exactly when its problems are, so there is nothing left to
|
||||||
|
# reconcile.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
|
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
|
||||||
@@ -19,7 +24,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
dry_run:
|
dry_run:
|
||||||
description: Report finished topics without closing anything
|
description: Report finished topics and reviews without closing anything
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
@@ -34,7 +39,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
close:
|
close:
|
||||||
# The `issues` trigger fires for every issue in the repo; only closing a
|
# The `issues` trigger fires for every issue in the repo; only closing a
|
||||||
# problem sub-issue can ever complete a topic's core set.
|
# problem sub-issue can ever complete a topic's core set or a review.
|
||||||
if: github.event_name != 'issues' || contains(github.event.issue.labels.*.name, 'problem')
|
if: github.event_name != 'issues' || contains(github.event.issue.labels.*.name, 'problem')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -5,53 +5,19 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# The Worker only. The docs site is built and published from prdlk/cs, which
|
||||||
|
# reads docs/ from this repo over HTTP (Blume's mdxRemote()), so this repo has
|
||||||
|
# no Pages build.
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
# The Worker deploy is idempotent (wrangler uploads the whole bundle), so on a
|
# The Worker deploy is idempotent (wrangler uploads the whole bundle), so on a
|
||||||
# burst of pushes only the last one matters: cancel-in-progress is correct for
|
# burst of pushes only the last one matters: cancel-in-progress is correct.
|
||||||
# both the Pages build and the Worker job.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy
|
group: deploy
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22 # Blume requires 22.12+
|
|
||||||
|
|
||||||
- uses: oven-sh/setup-bun@v2 # skip if you use npm/pnpm
|
|
||||||
|
|
||||||
- run: bun install --frozen-lockfile
|
|
||||||
# blume.config.ts pulls the /changelog pages from this repo's GitHub
|
|
||||||
# releases (githubReleases source) at build time. The token authenticates
|
|
||||||
# that fetch — required if the repo is private, and it lifts the shared
|
|
||||||
# runner's unauthenticated API rate limit. `contents: read` covers it.
|
|
||||||
- run: bun run build
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: apps/docs/dist
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
steps:
|
|
||||||
- id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
|
|
||||||
# Deploys on every main push (not path-filtered): apps/api/data/schedule.json
|
# Deploys on every main push (not path-filtered): apps/api/data/schedule.json
|
||||||
# is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
|
# is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
|
||||||
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit),
|
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit),
|
||||||
|
|||||||
@@ -37,9 +37,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
.blume/
|
|
||||||
/apps/docs/docs/.wrangler/state/v3
|
|
||||||
.blume-verify/
|
|
||||||
|
|
||||||
# api worker
|
# api worker
|
||||||
apps/api/.dev.vars
|
apps/api/.dev.vars
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
LeetCode interview-prep campaign (2026-08-17 → Nov 3: eight weeks of topics, then the Review-issue phase; the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) run as a repo: solution files under `work/`, the docs content under `docs/` (MDX only — the Blume site that publishes it lives in [prdlk/cs](https://github.com/prdlk/cs) and reads `docs/` over HTTP), GitHub Actions that reconcile issues, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
||||||
|
|
||||||
## Architecture & Data Flow
|
## Architecture & Data Flow
|
||||||
|
|
||||||
@@ -10,11 +10,12 @@ Three owners, one direction of truth:
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
|
work[work/ solutions] -->|bun run sync| docs[docs/*.mdx + docs/site.json]
|
||||||
|
docs -->|mdxRemote + site.json fetch| Site[prdlk/cs docs site]
|
||||||
work -->|close-solved.yml| PI[problem issues]
|
work -->|close-solved.yml| PI[problem issues]
|
||||||
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
|
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
|
||||||
PI -->|close-topics.yml| TI[topic issues]
|
PI -->|close-topics.yml| TI[topic + Review N issues]
|
||||||
PI -->|close-solved.yml release job| Rel[timever release per finished topic]
|
PI -->|close-solved.yml release job| Rel[timever release per finished topic / Review N]
|
||||||
GH[GitHub issues = catalog] -->|nightly reconcile| D1
|
GH[GitHub issues = catalog] -->|nightly reconcile| D1
|
||||||
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
||||||
Mail -->|GET /log| D1
|
Mail -->|GET /log| D1
|
||||||
@@ -38,7 +39,9 @@ flowchart LR
|
|||||||
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
||||||
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
||||||
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
||||||
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first — Blume's changelog makes it the page's meta description — then closed on the finish day / earlier / open at release, with solution links at the tag). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is these releases (`githubReleases` in `apps/docs/blume.config.ts`, fetched at build with `GITHUB_TOKEN`), dated by the timever tag, not `published_at` (which the API cannot backdate): `releasesDatedByTag` rewrites each timever release's `published_at` before Blume maps it, via `custom(githubReleasesSource(…))` because the config-level `githubReleases()` takes no `fetchImpl`. Changing the tag format breaks the changelog dates.
|
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs site's `/changelog` (prdlk/cs `pages/changelog.astro` + `lib/releases.ts`) fetches this repo's releases at its build, dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are its cross-repo contract with `render()`.
|
||||||
|
- **Review issues re-parent problems; topics still own them.** The transfer phase's 20 `Review <N> — <Phase>` issues (label `review`, milestone `Review — Transfer & Mocks`; not the Worker's `Review — Week N` gate issues) hold open optional/deferred problems as sub-issues in solve order, moved there with `replace_parent`. `apps/api/src/reviews.ts` is the ONE recovery of ownership — `readReviews()` (one GraphQL request per 50 `review` issues, cheap enough for the Worker's cron subrequest budget) reads each moved problem's topic off its last `parent_issue_removed` event naming a topic, `movedFromTopics()` groups them — shared by the Worker's catalog reconcile (a moved problem's D1 row keeps syncing its labels and `topic_issue`) and `apps/cli/topics.ts` (`readProblems()` returns a topic's sub-issues plus its moved problems, so the finished rule and existing release bodies do not change when a problem moves). A Review issue finishes by `completion()` in `topics.ts` — every sub-issue closed, any set — which `close-topics.ts` closes it on and `release-topics.ts` releases it on, exactly like a topic: timever of the last close, same `<!-- release-topics: #N -->` marker (issue numbers never collide), body fields from the review's `**Scheduled:**`/`**Phase:**`/`**Load:**` lines.
|
||||||
|
- **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only, Oct 1 → Nov 3 — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it).
|
||||||
|
|
||||||
## Key Directories
|
## Key Directories
|
||||||
|
|
||||||
@@ -46,10 +49,10 @@ flowchart LR
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
||||||
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
||||||
| `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 guide page `/guides/<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/guides/` (the `Guides` 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 `/guides/<slug>`). Sync finds a guide by slug anywhere under `guides/` and creates a placeholder only when none exists or it is empty — at the `guides/` 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 `guides/meta.ts`. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; the five `/progress` cards `SolvedRing`/`SubmissionHeatmap`/`SubmissionChart`/`TopicBubbles`/`RecentSolves`, and `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigation.json` reports for the topic folder, inert until a topic has pages), `components/<Name>.astro` registered in `components.ts` is static markup plus a custom element for behaviour, the way Blume's own `Frame` is built (`Carousel`/`CarouselImage`: `src` takes a URL, `/file.png`, or the page-relative `../../public/file.png`) |
|
| `docs/` | The docs content, and nothing of the site: no `blume.config.ts`, no `meta.ts`, no components. prdlk/cs publishes it with Blume's `mdxRemote()` source, which fetches `.md`/`.mdx` files only. `docs/(0N-<phase>)/<topic>/(problems)/<num>_<slug>.mdx` is generated by sync; `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is a topic's hand-written landing page, and `index.mdx`/`keywords.mdx`/`progress.mdx` sit at the root. The phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment, and `(problems)` is a second `(group)` inside it, so `tree-dfs/(problems)/226_invert-binary-tree.mdx` routes to `/tree-dfs/invert-binary-tree` (Blume sorts by the `<num>_` prefix and strips it from the route). `docs/site.json` is sync's generated manifest and the site's ONE other input: `folders` (path under `docs/` → `{ title, order, icon?, display? }`, exactly what a `meta.ts` in that folder would say — every topic `display: "page"`, so it is one sidebar row whose panel holds its landing page and a "Problems" section) and `redirects` (the `/<num>` short links). Images are root-absolute (``, `<CarouselImage src='/binary_search.001.png' />`) and served from the site's `public/` — a page-relative path cannot resolve once the MDX is fetched over HTTP. Frontmatter keys beyond Blume's (`topics:`, `video:`, `issue:`) and the MDX components pages use (`<CodeGroup>`, `<Carousel>`/`<CarouselImage>`, the islands) are the site's to declare — add a new one there before a page uses it |
|
||||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
||||||
| `.github/workflows/` | deploy (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) |
|
| `.github/workflows/` | deploy (Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push; the docs site deploys from prdlk/cs), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
||||||
| `.githooks/` | 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 |
|
| `.githooks/` | `bun install` points `core.hooksPath` here (root `prepare` script, no husky). Its `pre-commit` hook (run `bun run sync`, stage only what sync changed — pages and `docs/site.json`) was removed in `e63d517`, so nothing runs on commit today: run `bun run sync` yourself before committing a solution, or its page and short link ship a commit later |
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
@@ -69,12 +72,11 @@ bun run test # run ONE solution against LeetCode's judge (not a test
|
|||||||
# most recently tested first, stamped in the db on exit 0)
|
# most recently tested first, stamped in the db on exit 0)
|
||||||
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
|
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
|
||||||
# hidden; last_submitted stamped in the db on exit 0)
|
# hidden; last_submitted stamped in the db on exit 0)
|
||||||
bun run sync # work/1 → apps/docs/content/ pages + apps/docs/redirects.ts (/<num> short
|
bun run sync # work/1 → docs/ pages + docs/site.json (folder meta + /<num> short
|
||||||
# links; re-solves are never published)
|
# links; re-solves are never published)
|
||||||
bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page
|
bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page
|
||||||
# (channel search scraped, no API key; --write adds the
|
# (channel search scraped, no API key; --write adds the
|
||||||
# `video:` key; "no NeetCode match" pages stay untouched)
|
# `video:` key; "no NeetCode match" pages stay untouched)
|
||||||
bun run dev|build # Blume docs site (runs in apps/docs/)
|
|
||||||
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
||||||
bun run close-topics -- --dry-run
|
bun run close-topics -- --dry-run
|
||||||
bun run release-topics -- --dry-run # prints every would-be topic release body
|
bun run release-topics -- --dry-run # prints every would-be topic release body
|
||||||
@@ -109,23 +111,22 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
|||||||
- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
|
||||||
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`, and `OPENROUTER_API_KEY`, whose one copy is the repo's Actions secret (deploy.yml puts it before every deploy — rotate it there).
|
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`, and `OPENROUTER_API_KEY`, whose one copy is the repo's Actions secret (deploy.yml puts it before every deploy — rotate it there).
|
||||||
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
|
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
|
||||||
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`.
|
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
|
||||||
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
||||||
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) is filled once — on create, or on any run that finds it missing — never refreshed (delete the key to refetch; offline, the page is written without it and the next sync retries). `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`. The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
|
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `docs/(0N-<phase>)/<topic>/`, with every problem page in the topic's `(problems)/` group as `<num>_<slug>.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). `docs/site.json` is wholly script-owned and rewritten whenever it differs, because `mdxRemote()` reads no `meta.ts`: its `folders` are what a `meta.ts` per phase and topic folder would carry — a phase `N. Name` at `order: N`, a topic its exact label, curriculum order, `TOPIC_ICONS` icon and `display: "page"` — all derived from the README, so a rename there is a rename on the site; its `redirects` are the `/<num>` → `/<topic>/<slug>` short links. Which tags the site teaches as topics (`topic-landings.ts`) and its Appendix glossary (`appendix.ts`) are the site's, in prdlk/cs: sync no longer checks them. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing through an emptied `(problems)` and topic into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
|
||||||
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
|
- `apps/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.
|
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs site's `/progress` islands (prdlk/cs `lib/stats.ts`) and `bun run stats`, so its shape is a cross-repo contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
|
||||||
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The 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 campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`). `/api/stats` and `/ask` reflect a requesting origin only when it is in the `DOCS_ORIGIN` var (`apps/api/wrangler.jsonc`, comma-separated: `https://lc.prd.lk,https://prdlk.github.io`) — a new host for the docs site must be added there.
|
||||||
- `apps/docs/blume.config.ts` — site base `/leetcode`; imports `apps/docs/redirects.ts`, sync-generated (`/<num>` → `/<topic>/<slug>`, one row per page, exact root-relative paths — Blume adds the base and, for the static Pages build, emits a meta-refresh page per rule plus `blume-redirects.json`); `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`).
|
|
||||||
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
|
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
|
||||||
|
|
||||||
## Runtime/Tooling Preferences
|
## Runtime/Tooling Preferences
|
||||||
|
|
||||||
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`. Node 22 appears only in `deploy.yml` because Blume requires it.
|
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`.
|
||||||
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. Two exceptions, each confined to one surface. The digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. And the docs `/progress` islands: shadcn/ui (`recharts`, `radix-ui`, `class-variance-authority`, `clsx`, `tailwind-merge`) plus `d3-hierarchy` for the bubble packing, client-side in `apps/docs` only. Do not extend either exception to any other module.
|
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. One exception, confined to one surface: the digest email, `apps/api/src/email.tsx`, renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend it to any other module. (The docs site's own dependencies are prdlk/cs's.)
|
||||||
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
|
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
|
||||||
|
|
||||||
## Testing & QA
|
## Testing & QA
|
||||||
|
|
||||||
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
|
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
|
||||||
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
|
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
|
||||||
- Docs changes: `bunx blume build --isolated` (from `apps/docs/`) must pass with no new warnings (then `rm -rf apps/docs/.blume-verify`); after sync, review only *created* pages (prose transforms are heuristic).
|
- Docs changes: the site builds in prdlk/cs, which must pass `bunx blume build --isolated` with no new warnings against this repo's `docs/`; after sync, review only *created* pages (prose transforms are heuristic) and `docs/site.json`'s diff.
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
|
<sub>Interview-prep fight camp: eight weeks of topics, then a review phase to Nov 3. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
|
||||||
New topic Mon–Fri, gate on Saturday, Sunday off — every solve comes back at <strong>+3</strong> and <strong>+7</strong> days,
|
New topic Mon–Fri, gate on Saturday, Sunday off — every solve comes back at <strong>+3</strong> and <strong>+7</strong> days,
|
||||||
the two windows that keep the rest day clear (a Thursday solve's +3 slides to Monday).<br>
|
the two windows that keep the rest day clear (a Thursday solve's +3 slides to Monday).<br>
|
||||||
Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub>
|
Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub>
|
||||||
|
|||||||
+6
-3
@@ -17,9 +17,12 @@ Live at `https://srs-api.prdlk.workers.dev`.
|
|||||||
- **The repo owns the plan** — `data/schedule.json`, human-edited,
|
- **The repo owns the plan** — `data/schedule.json`, human-edited,
|
||||||
bundled at deploy. Git history is its audit log. Each date is a
|
bundled at deploy. Git history is its audit log. Each date is a
|
||||||
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
|
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
|
||||||
fixed numbers), `gate` (Saturday's blind set, verbatim), or `sweep` (redo
|
fixed numbers), `review` (a `Review <N> — <Phase>` issue with its problems
|
||||||
every miss, nothing new). Numbers a `gate` or `problems` entry names are
|
in solve order; Mon/Tue/Thu/Fri only), `gate` (Saturday's blind set,
|
||||||
`RESERVED`: the drill and gate samplers never draw them.
|
verbatim; `[]` skips that week's checkpoint), or `sweep` (redo every miss,
|
||||||
|
nothing new). Numbers a `gate`, `problems` or `review` entry names are
|
||||||
|
`RESERVED`: the drill and gate samplers never draw them. The catalog
|
||||||
|
reconcile reports any drift between a `review` entry and its issue.
|
||||||
- The GitHub Project mirror writes `Target Date` and nothing else: it is
|
- The GitHub Project mirror writes `Target Date` and nothing else: it is
|
||||||
best-effort, so failures log warnings and never block a D1 write, and topic
|
best-effort, so failures log warnings and never block a D1 write, and topic
|
||||||
rows are never written. SRS stage and first-attempt result stay in D1 only —
|
rows are never written. SRS stage and first-attempt result stay in D1 only —
|
||||||
|
|||||||
@@ -22,7 +22,25 @@
|
|||||||
"2026-09-28": { "topics": [24] },
|
"2026-09-28": { "topics": [24] },
|
||||||
"2026-09-29": { "topics": [25] },
|
"2026-09-29": { "topics": [25] },
|
||||||
"2026-09-30": { "topics": [26] },
|
"2026-09-30": { "topics": [26] },
|
||||||
"2026-10-01": { "problems": [399, 261, 909, 77, 52] },
|
"2026-10-01": { "review": { "issue": 225, "title": "Review 1 — Linear Structures", "problems": [205, 974, 209, 34, 42] } },
|
||||||
"2026-10-02": { "sweep": true },
|
"2026-10-02": { "review": { "issue": 226, "title": "Review 2 — Linear Structures", "problems": [290, 525, 189, 1004, 84] }, "sweep": true },
|
||||||
"2026-10-03": { "gate": [347, 1254, 1971, 131, 494, 986] }
|
"2026-10-03": { "gate": [] },
|
||||||
|
"2026-10-05": { "review": { "issue": 227, "title": "Review 3 — Linear Structures", "problems": [643, 128, 162, 71, 4] } },
|
||||||
|
"2026-10-06": { "review": { "issue": 228, "title": "Review 4 — Linear Structures", "problems": [496, 80, 901, 76, 224] } },
|
||||||
|
"2026-10-08": { "review": { "issue": 229, "title": "Review 5 — Nodal & Grid", "problems": [876, 19, 36] } },
|
||||||
|
"2026-10-09": { "review": { "issue": 230, "title": "Review 6 — Nodal & Grid", "problems": [92, 82, 380] } },
|
||||||
|
"2026-10-12": { "review": { "issue": 231, "title": "Review 7 — Nodal & Grid", "problems": [143, 86, 289] } },
|
||||||
|
"2026-10-13": { "review": { "issue": 232, "title": "Review 8 — Nodal & Grid", "problems": [61, 138] } },
|
||||||
|
"2026-10-15": { "review": { "issue": 233, "title": "Review 9 — Hierarchical", "problems": [100, 112, 530, 1046, 103, 124] } },
|
||||||
|
"2026-10-16": { "review": { "issue": 234, "title": "Review 10 — Hierarchical", "problems": [572, 543, 703, 105, 295] } },
|
||||||
|
"2026-10-19": { "review": { "issue": 235, "title": "Review 11 — Hierarchical", "problems": [101, 637, 173, 621, 502] } },
|
||||||
|
"2026-10-20": { "review": { "issue": 236, "title": "Review 12 — Hierarchical", "problems": [108, 129, 373, 648, 212] } },
|
||||||
|
"2026-10-22": { "review": { "issue": 237, "title": "Review 13 — Relational", "problems": [1971, 695, 547, 433] } },
|
||||||
|
"2026-10-23": { "review": { "issue": 238, "title": "Review 14 — Relational", "problems": [130, 261, 909, 1462] } },
|
||||||
|
"2026-10-26": { "review": { "issue": 239, "title": "Review 15 — Relational", "problems": [286, 1254, 721, 399] } },
|
||||||
|
"2026-10-27": { "review": { "issue": 240, "title": "Review 16 — Relational", "problems": [1091, 802, 269] } },
|
||||||
|
"2026-10-29": { "review": { "issue": 241, "title": "Review 17 — Decision Space", "problems": [746, 228, 17, 77, 213, 63, 122] } },
|
||||||
|
"2026-10-30": { "review": { "issue": 242, "title": "Review 18 — Decision Space", "problems": [22, 90, 139, 120, 45, 253, 72] } },
|
||||||
|
"2026-11-02": { "review": { "issue": 243, "title": "Review 19 — Decision Space", "problems": [79, 131, 91, 494, 763, 986, 920] } },
|
||||||
|
"2026-11-03": { "review": { "issue": 244, "title": "Review 20 — Decision Space", "problems": [300, 647, 97, 221, 918, 452, 52] } }
|
||||||
}
|
}
|
||||||
+53
-7
@@ -9,9 +9,16 @@
|
|||||||
* issue title — the title carries only the LC number and the problem's name,
|
* issue title — the title carries only the LC number and the problem's name,
|
||||||
* so relabelling a problem is enough to move it and titles stay readable.
|
* so relabelling a problem is enough to move it and titles stay readable.
|
||||||
* The `problem` label is what marks a sub-issue as curriculum.
|
* The `problem` label is what marks a sub-issue as curriculum.
|
||||||
|
*
|
||||||
|
* A topic's problems are its sub-issues PLUS those re-parented into a
|
||||||
|
* `Review <N> — <Phase>` issue (reviews.ts recovers which topic each came
|
||||||
|
* from). Walking topic sub-issues alone would silently freeze a moved
|
||||||
|
* problem's row: upserts only, so nothing breaks — its labels just stop
|
||||||
|
* syncing.
|
||||||
*/
|
*/
|
||||||
import { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
|
import { DIFFICULTIES, SCHEDULE, SETS, workingDay } from "./srs.ts";
|
||||||
import type { GitHub } from "./github.ts";
|
import type { GitHub } from "./github.ts";
|
||||||
|
import { movedFromTopics, readReviews, type Review } from "./reviews.ts";
|
||||||
|
|
||||||
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||||
|
|
||||||
@@ -21,6 +28,37 @@ const DEFER_FROM = "2026-09-28";
|
|||||||
export interface ReconcileReport {
|
export interface ReconcileReport {
|
||||||
topics: number;
|
topics: number;
|
||||||
problems: number;
|
problems: number;
|
||||||
|
/** Where schedule.json's review days disagree with the Review issues; empty when in step. */
|
||||||
|
reviewDrift: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* schedule.json copies each review day's issue, title and problems so the
|
||||||
|
* digest and RESERVED need no GitHub call; the issues stay the truth. Compared
|
||||||
|
* here because this is the one place that already holds both.
|
||||||
|
*/
|
||||||
|
function reviewDrift(reviews: Review[]): string[] {
|
||||||
|
const drift: string[] = [];
|
||||||
|
const booked = new Map<number, string>();
|
||||||
|
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||||
|
if (!day.review) continue;
|
||||||
|
const { issue, title, problems } = day.review;
|
||||||
|
booked.set(issue, date);
|
||||||
|
const review = reviews.find((r) => r.number === issue);
|
||||||
|
if (!review) {
|
||||||
|
drift.push(`${date}: #${issue} is not a "Review <N> — <Phase>" issue`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (review.title !== title) drift.push(`${date}: #${issue} is titled "${review.title}", schedule says "${title}"`);
|
||||||
|
const actual = review.problems.map((p) => Number(p.title.match(TITLE_RE)?.[1]));
|
||||||
|
if (actual.join(",") !== problems.join(",")) {
|
||||||
|
drift.push(`${date}: #${issue} holds LC ${actual.join(", ")}, schedule says LC ${problems.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const review of reviews) {
|
||||||
|
if (!booked.has(review.number)) drift.push(`#${review.number} "${review.title}" has no day in schedule.json`);
|
||||||
|
}
|
||||||
|
return drift;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Label names on one issue payload, narrowed with guards rather than cast. */
|
/** Label names on one issue payload, narrowed with guards rather than cast. */
|
||||||
@@ -72,24 +110,32 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reviews = await readReviews(gh);
|
||||||
|
const drift = reviewDrift(reviews);
|
||||||
|
for (const line of drift) console.warn(`catalog: review drift — ${line}`);
|
||||||
|
const moved = movedFromTopics(reviews);
|
||||||
let problems = 0;
|
let problems = 0;
|
||||||
let deferredSeen = 0;
|
let deferredSeen = 0;
|
||||||
for (const t of topics) {
|
for (const t of topics) {
|
||||||
|
const members: { number: number; title: string; labels: string[] }[] = [];
|
||||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
|
||||||
if (!raw || typeof raw !== "object") continue;
|
if (!raw || typeof raw !== "object") continue;
|
||||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||||
const labels = labelNames(raw);
|
members.push({ number: raw.number, title: raw.title, labels: labelNames(raw) });
|
||||||
|
}
|
||||||
|
members.push(...(moved.get(t.number) ?? []));
|
||||||
|
for (const { number, title, labels } of members) {
|
||||||
if (!labels.includes("problem")) continue; // non-curriculum sub-issue
|
if (!labels.includes("problem")) continue; // non-curriculum sub-issue
|
||||||
const m = raw.title.match(TITLE_RE);
|
const m = title.match(TITLE_RE);
|
||||||
if (!m) {
|
if (!m) {
|
||||||
console.warn(`catalog: #${raw.number} is a problem but not titled "LC <n> · <name>"`);
|
console.warn(`catalog: #${number} is a problem but not titled "LC <n> · <name>"`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`));
|
const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`));
|
||||||
const set = SETS.find((s) => labels.includes(`set:${s}`));
|
const set = SETS.find((s) => labels.includes(`set:${s}`));
|
||||||
if (!difficulty || !set) {
|
if (!difficulty || !set) {
|
||||||
console.warn(`catalog: #${raw.number} is missing a diff:/set: label — skipped`);
|
console.warn(`catalog: #${number} is missing a diff:/set: label — skipped`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const lc = Number(m[1]);
|
const lc = Number(m[1]);
|
||||||
@@ -106,7 +152,7 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
|||||||
ON CONFLICT(lc_number) DO UPDATE SET
|
ON CONFLICT(lc_number) DO UPDATE SET
|
||||||
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
|
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
|
||||||
)
|
)
|
||||||
.bind(lc, raw.number, t.number, m[2]!, difficulty, set, defer),
|
.bind(lc, number, t.number, m[2]!, difficulty, set, defer),
|
||||||
);
|
);
|
||||||
problems++;
|
problems++;
|
||||||
}
|
}
|
||||||
@@ -116,5 +162,5 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
|||||||
for (let i = 0; i < statements.length; i += 50) {
|
for (let i = 0; i < statements.length; i += 50) {
|
||||||
await db.batch(statements.slice(i, i + 50));
|
await db.batch(statements.slice(i, i + 50));
|
||||||
}
|
}
|
||||||
return { topics: topics.length, problems };
|
return { topics: topics.length, problems, reviewDrift: drift };
|
||||||
}
|
}
|
||||||
+15
-2
@@ -8,8 +8,9 @@
|
|||||||
* Retrieval rules: review, drill and sweep rows carry number + difficulty
|
* Retrieval rules: review, drill and sweep rows carry number + difficulty
|
||||||
* only — never the topic, never a solution link. `DigestRow` has no field for
|
* only — never the topic, never a solution link. `DigestRow` has no field for
|
||||||
* either, so the rule holds by construction. The plan's own sections — a
|
* either, so the rule holds by construction. The plan's own sections — a
|
||||||
* learning day's topics (core, then the optional "if time" list) and a set
|
* learning day's topics (core, then the optional "if time" list), a set
|
||||||
* day's problems — are the only labeled, linked ones. At most REVIEW_CAP (3)
|
* day's problems, and a review day's `Review <N>` issue — are the only
|
||||||
|
* labeled, linked ones. At most REVIEW_CAP (3)
|
||||||
* reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
|
* reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
|
||||||
* is light, and never on a sweep day, which assigns every open miss and
|
* is light, and never on a sweep day, which assigns every open miss and
|
||||||
* nothing new. Overflow is levelled, not stacked: sendDigest gives every
|
* nothing new. Overflow is levelled, not stacked: sendDigest gives every
|
||||||
@@ -150,6 +151,18 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
|
|||||||
data.assigned = rows.map((p) => topicRow(env, p));
|
data.assigned = rows.map((p) => topicRow(env, p));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A review day: the `Review <N> — <Phase>` issue's problems, in its solve
|
||||||
|
// order, linked like a set day's and headed by the issue itself.
|
||||||
|
if (plan.review) {
|
||||||
|
const { rows, missing } = await lookupProblems(db, plan.review.problems);
|
||||||
|
if (missing.length) console.warn(`digest ${date}: review problems not in the catalog: ${missing.join(", ")}`);
|
||||||
|
data.review = {
|
||||||
|
title: plan.review.title,
|
||||||
|
url: `https://github.com/${env.REPO}/issues/${plan.review.issue}`,
|
||||||
|
rows: rows.map((p) => topicRow(env, p)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// A sweep day works the miss log: every problem still standing on a
|
// A sweep day works the miss log: every problem still standing on a
|
||||||
// failure, minus the ones today's review list already carries.
|
// failure, minus the ones today's review list already carries.
|
||||||
if (plan.sweep) {
|
if (plan.sweep) {
|
||||||
|
|||||||
+41
-8
@@ -71,6 +71,13 @@ const LAST_SEEN: Record<string, string> = {
|
|||||||
retired: "retired",
|
retired: "retired",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A review day's rules, the same ones its GitHub issue carries — one sentence
|
||||||
|
// here so the HTML and the plain-text alternative cannot word them apart.
|
||||||
|
const REVIEW_RULES =
|
||||||
|
"90 minutes, top to bottom — easy to hard; skip what is already solved. " +
|
||||||
|
"Before each submit, say the time and space complexity out loud and trace one edge case. " +
|
||||||
|
"Whatever is unfinished stays open.";
|
||||||
|
|
||||||
// ── data model ───────────────────────────────────────────────────
|
// ── data model ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
|
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
|
||||||
@@ -101,7 +108,7 @@ export interface TopicSection {
|
|||||||
export interface DigestData {
|
export interface DigestData {
|
||||||
/** "Tuesday, August 25" */
|
/** "Tuesday, August 25" */
|
||||||
day: string;
|
day: string;
|
||||||
/** "Day 9 of 56 · Week 2" */
|
/** "Day 9 of 79 · Week 2" — the length is CAMPAIGN_DAYS, derived from the plan. */
|
||||||
progress: string;
|
progress: string;
|
||||||
streak: number;
|
streak: number;
|
||||||
rest: boolean;
|
rest: boolean;
|
||||||
@@ -109,6 +116,8 @@ export interface DigestData {
|
|||||||
topics: TopicSection[];
|
topics: TopicSection[];
|
||||||
/** A set day's problems, assigned by number rather than by topic. */
|
/** A set day's problems, assigned by number rather than by topic. */
|
||||||
assigned?: TopicRow[];
|
assigned?: TopicRow[];
|
||||||
|
/** A review day: the `Review <N> — <Phase>` issue and its problems in solve order. */
|
||||||
|
review?: { title: string; url: string; rows: TopicRow[] };
|
||||||
reviews: DigestRow[];
|
reviews: DigestRow[];
|
||||||
/** Reviews past the daily cap; levelled onto the coming days. */
|
/** Reviews past the daily cap; levelled onto the coming days. */
|
||||||
carried: number;
|
carried: number;
|
||||||
@@ -293,11 +302,13 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
||||||
const lead = data.topics[0]
|
const lead = data.topics[0]
|
||||||
? `, starting with ${data.topics[0].name}`
|
? `, starting with ${data.topics[0].name}`
|
||||||
: data.assigned
|
: data.review
|
||||||
? ", starting with today's set"
|
? `, starting with ${data.review.title}`
|
||||||
: data.sweep
|
: data.assigned
|
||||||
? " — sweep day, nothing new"
|
? ", starting with today's set"
|
||||||
: "";
|
: data.sweep
|
||||||
|
? " — sweep day, nothing new"
|
||||||
|
: "";
|
||||||
const preview = data.rest ? "Rest day — nothing to do but rest." : `${load} to work through today${lead}.`;
|
const preview = data.rest ? "Rest day — nothing to do but rest." : `${load} to work through today${lead}.`;
|
||||||
|
|
||||||
const passed = data.yesterday.total - data.yesterday.failed.length;
|
const passed = data.yesterday.total - data.yesterday.failed.length;
|
||||||
@@ -350,6 +361,19 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
</Section>
|
</Section>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{data.review ? (
|
||||||
|
<Section style={{ marginTop: "28px" }}>
|
||||||
|
<Text style={label}>Today's review</Text>
|
||||||
|
<Text style={{ fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||||
|
<Link href={data.review.url} style={{ color: INK, textDecoration: "underline" }}>
|
||||||
|
{data.review.title}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
<Text style={hint}>{REVIEW_RULES}</Text>
|
||||||
|
<TopicTable rows={data.review.rows} />
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{data.assigned ? (
|
{data.assigned ? (
|
||||||
<Section style={{ marginTop: "28px" }}>
|
<Section style={{ marginTop: "28px" }}>
|
||||||
<Text style={label}>Today's set</Text>
|
<Text style={label}>Today's set</Text>
|
||||||
@@ -407,14 +431,14 @@ function DigestEmail({ data }: { data: DigestData }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Eight weeks at a glance — the one thing here that is pure
|
{/* The whole campaign at a glance — the one thing here that is pure
|
||||||
encouragement rather than instruction. */}
|
encouragement rather than instruction. */}
|
||||||
<Section style={{ marginTop: "28px" }}>
|
<Section style={{ marginTop: "28px" }}>
|
||||||
<Text style={label}>Every day you showed up</Text>
|
<Text style={label}>Every day you showed up</Text>
|
||||||
<Link href={data.progressUrl}>
|
<Link href={data.progressUrl}>
|
||||||
<Img
|
<Img
|
||||||
src={data.heatmapUrl}
|
src={data.heatmapUrl}
|
||||||
alt="Attempt heatmap across the eight-week campaign"
|
alt="Attempt heatmap across the whole campaign"
|
||||||
width="552"
|
width="552"
|
||||||
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
|
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
|
||||||
/>
|
/>
|
||||||
@@ -473,6 +497,15 @@ function plainDigest(data: DigestData): string {
|
|||||||
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
|
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (data.review) {
|
||||||
|
out.push(
|
||||||
|
"",
|
||||||
|
`TODAY'S REVIEW — ${data.review.title}`,
|
||||||
|
` ${data.review.url}`,
|
||||||
|
REVIEW_RULES,
|
||||||
|
...data.review.rows.map(linked),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (data.assigned) {
|
if (data.assigned) {
|
||||||
out.push(
|
out.push(
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* The gate half is a topic-blind quiz. When the plan fixes the set
|
* The gate half is a topic-blind quiz. When the plan fixes the set
|
||||||
* (`gate` in data/schedule.json) that list IS the gate, verbatim — every
|
* (`gate` in data/schedule.json) that list IS the gate, verbatim — every
|
||||||
* number must be a catalog problem, or the issue is not created, because a
|
* number must be a catalog problem, or the issue is not created, because a
|
||||||
* `/done` on an uncatalogued number can never be logged. Otherwise the set
|
* `/done` on an uncatalogued number can never be logged; an empty list skips
|
||||||
|
* the week's checkpoint outright. Otherwise the set
|
||||||
* is sampled: one unsolved optional problem per topic scheduled this week
|
* is sampled: one unsolved optional problem per topic scheduled this week
|
||||||
* (which is why daily drills only draw from earlier weeks), never one the
|
* (which is why daily drills only draw from earlier weeks), never one the
|
||||||
* plan has reserved. The recap half is generated data, deliberately NOT a
|
* plan has reserved. The recap half is generated data, deliberately NOT a
|
||||||
@@ -77,6 +78,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
|||||||
// week without topics, across every topic that still has an unsolved pool.
|
// week without topics, across every topic that still has an unsolved pool.
|
||||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||||
const planned = SCHEDULE[date]?.gate;
|
const planned = SCHEDULE[date]?.gate;
|
||||||
|
if (planned?.length === 0) return { created: false, reason: `the plan skips the week ${week} checkpoint` };
|
||||||
if (planned) {
|
if (planned) {
|
||||||
const { rows, missing } = await lookupProblems(db, planned);
|
const { rows, missing } = await lookupProblems(db, planned);
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { type GitHub, github } from "./github.ts";
|
|||||||
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
||||||
import { type Mirror, projectMirror } from "./mirror.ts";
|
import { type Mirror, projectMirror } from "./mirror.ts";
|
||||||
import {
|
import {
|
||||||
|
CAMPAIGN_DAYS,
|
||||||
CAMPAIGN_START,
|
CAMPAIGN_START,
|
||||||
type LogOutcome,
|
type LogOutcome,
|
||||||
type Stage,
|
type Stage,
|
||||||
@@ -333,10 +334,10 @@ export default {
|
|||||||
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
||||||
}
|
}
|
||||||
if (path === "/chart/heatmap.svg") {
|
if (path === "/chart/heatmap.svg") {
|
||||||
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS });
|
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: SVG_HEADERS });
|
||||||
}
|
}
|
||||||
if (path === "/chart/heatmap.png") {
|
if (path === "/chart/heatmap.png") {
|
||||||
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS });
|
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: PNG_HEADERS });
|
||||||
}
|
}
|
||||||
if (path === "/badge/gate.svg") {
|
if (path === "/badge/gate.svg") {
|
||||||
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* `Review <N> — <Phase>` issues: the transfer phase's day blocks. Problems were
|
||||||
|
* re-parented into them from their topics (`replace_parent` — a GitHub issue
|
||||||
|
* has exactly one parent), so a topic's sub-issue list no longer names every
|
||||||
|
* problem it owns. This module is the ONE recovery of that ownership: each
|
||||||
|
* moved problem's topic is its last `parent_issue_removed` event naming a
|
||||||
|
* topic, which GitHub records and no human edits.
|
||||||
|
*
|
||||||
|
* Two consumers must agree on it: the Worker's catalog reconcile
|
||||||
|
* (catalog.ts — else a moved problem's D1 row stops syncing its labels) and
|
||||||
|
* the CLI's topic rules (apps/cli/topics.ts — else close-topics and the
|
||||||
|
* release bodies change when a problem moves). Hence it lives here, the lower
|
||||||
|
* layer, the way apps/cli already imports srs.ts.
|
||||||
|
*
|
||||||
|
* Library: side-effect-free on import; the caller passes its GitHub client —
|
||||||
|
* the Worker's or the CLI's, which share the two members used here.
|
||||||
|
*/
|
||||||
|
import type { GitHub } from "./github.ts";
|
||||||
|
|
||||||
|
/** `Review 7 — Nodal & Grid`, never the Worker's weekly `Review — Week N` gate. */
|
||||||
|
const REVIEW_TITLE = /^Review \d+ — /;
|
||||||
|
const TOPIC_TITLE = /^Topic \d+ — /;
|
||||||
|
|
||||||
|
/** One problem issue as a topic or review lists it. */
|
||||||
|
export interface Problem {
|
||||||
|
number: number;
|
||||||
|
/** `LC <num> · <Name>`. */
|
||||||
|
title: string;
|
||||||
|
/** ISO instant the issue closed; null while it is open. */
|
||||||
|
closedAt: string | null;
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Review {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
/** Markdown body: `**Scheduled:**` / `**Phase:**` / `**Load:**` lines the release reads. */
|
||||||
|
body: string;
|
||||||
|
/** Sub-issues in the order GitHub lists them (the solve order). */
|
||||||
|
problems: (Problem & { fromTopic: number | null })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every `Review <N> — <Phase>` issue, open and closed, with its sub-issues.
|
||||||
|
* One GraphQL request per 50 `review`-labelled issues (the Worker's weekly
|
||||||
|
* gate issues share the label and are dropped by title): the sub-issues and
|
||||||
|
* the timeline filter ride along, so recovering each problem's source topic
|
||||||
|
* costs no extra request. That matters in the Worker, whose nightly cron
|
||||||
|
* spends its subrequest budget on the catalog walk and the digest.
|
||||||
|
*/
|
||||||
|
export async function readReviews(gh: Pick<GitHub, "repo" | "graphql">): Promise<Review[]> {
|
||||||
|
const [owner, name] = gh.repo.split("/");
|
||||||
|
const reviews: Review[] = [];
|
||||||
|
for (let after: string | null = null; ; ) {
|
||||||
|
// Shape fixed by the query below; GraphQL errors throw in gh.graphql().
|
||||||
|
const data = (await gh.graphql(
|
||||||
|
`query($owner: String!, $name: String!, $after: String) {
|
||||||
|
repository(owner: $owner, name: $name) {
|
||||||
|
issues(labels: ["review"], first: 50, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
nodes {
|
||||||
|
number title state body
|
||||||
|
subIssues(first: 50) {
|
||||||
|
pageInfo { hasNextPage }
|
||||||
|
nodes {
|
||||||
|
number title state closedAt
|
||||||
|
labels(first: 20) { nodes { name } }
|
||||||
|
timelineItems(itemTypes: [PARENT_ISSUE_REMOVED_EVENT], last: 10) {
|
||||||
|
nodes { ... on ParentIssueRemovedEvent { parent { number title } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ owner, name, after },
|
||||||
|
)) as {
|
||||||
|
repository: {
|
||||||
|
issues: {
|
||||||
|
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
||||||
|
nodes: {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: "OPEN" | "CLOSED";
|
||||||
|
body: string;
|
||||||
|
subIssues: {
|
||||||
|
pageInfo: { hasNextPage: boolean };
|
||||||
|
nodes: {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: "OPEN" | "CLOSED";
|
||||||
|
closedAt: string | null;
|
||||||
|
labels: { nodes: { name: string }[] };
|
||||||
|
timelineItems: { nodes: { parent: { number: number; title: string } | null }[] };
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const page = data.repository.issues;
|
||||||
|
for (const issue of page.nodes) {
|
||||||
|
if (!REVIEW_TITLE.test(issue.title)) continue;
|
||||||
|
// A review is one day's handful; more than a page means a mis-linked issue.
|
||||||
|
if (issue.subIssues.pageInfo.hasNextPage) throw new Error(`review #${issue.number} has over 50 sub-issues`);
|
||||||
|
reviews.push({
|
||||||
|
number: issue.number,
|
||||||
|
title: issue.title,
|
||||||
|
open: issue.state === "OPEN",
|
||||||
|
body: issue.body,
|
||||||
|
problems: issue.subIssues.nodes.map((p) => ({
|
||||||
|
number: p.number,
|
||||||
|
title: p.title,
|
||||||
|
closedAt: p.state === "CLOSED" ? p.closedAt : null,
|
||||||
|
labels: p.labels.nodes.map((l) => l.name),
|
||||||
|
// Oldest first, so the last topic it left is its owner; a later
|
||||||
|
// review-to-review move names a review and is skipped.
|
||||||
|
fromTopic:
|
||||||
|
p.timelineItems.nodes.findLast((e) => e.parent && TOPIC_TITLE.test(e.parent.title))?.parent?.number ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!page.pageInfo.hasNextPage) break;
|
||||||
|
after = page.pageInfo.endCursor;
|
||||||
|
}
|
||||||
|
return reviews.sort((a, b) => a.number - b.number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Topic -> the problems moved out of it into Review issues, in review order. */
|
||||||
|
export function movedFromTopics(reviews: Review[]): Map<number, Problem[]> {
|
||||||
|
const moved = new Map<number, Problem[]>();
|
||||||
|
for (const review of reviews) {
|
||||||
|
for (const { fromTopic, ...problem } of review.problems) {
|
||||||
|
if (fromTopic !== null) moved.set(fromTopic, [...(moved.get(fromTopic) ?? []), problem]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
@@ -134,6 +134,8 @@ describe("the plan", () => {
|
|||||||
expect(isRestDay(date)).toBe(false);
|
expect(isRestDay(date)).toBe(false);
|
||||||
if (day.gate) expect(weekdayOf(date)).toBe(6);
|
if (day.gate) expect(weekdayOf(date)).toBe(6);
|
||||||
if (day.topics) expect(weekdayOf(date)).toBeLessThan(6);
|
if (day.topics) expect(weekdayOf(date)).toBeLessThan(6);
|
||||||
|
// Reviews run Mon/Tue and Thu/Fri; Wednesday and the weekend stay free.
|
||||||
|
if (day.review) expect([1, 2, 4, 5]).toContain(weekdayOf(date));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,6 +152,20 @@ describe("the plan", () => {
|
|||||||
expect(topicDate(999)).toBeUndefined();
|
expect(topicDate(999)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a review is booked once, and no problem sits in two reviews", () => {
|
||||||
|
const issues = new Set<number>();
|
||||||
|
const problems = new Set<number>();
|
||||||
|
for (const day of Object.values(SCHEDULE)) {
|
||||||
|
if (!day.review) continue;
|
||||||
|
expect(issues.has(day.review.issue)).toBe(false);
|
||||||
|
issues.add(day.review.issue);
|
||||||
|
for (const lc of day.review.problems) {
|
||||||
|
expect(problems.has(lc)).toBe(false);
|
||||||
|
problems.add(lc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("every gate and set problem is reserved from the samplers", () => {
|
test("every gate and set problem is reserved from the samplers", () => {
|
||||||
for (const day of Object.values(SCHEDULE)) {
|
for (const day of Object.values(SCHEDULE)) {
|
||||||
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
|
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
|
||||||
|
|||||||
+33
-9
@@ -22,8 +22,8 @@ import scheduleJson from "../data/schedule.json";
|
|||||||
|
|
||||||
// ── dates (America/New_York) ─────────────────────────────────────
|
// ── dates (America/New_York) ─────────────────────────────────────
|
||||||
|
|
||||||
|
/** Day 1. The last day is not a constant: see CAMPAIGN_END below the schedule. */
|
||||||
export const CAMPAIGN_START = "2026-08-17";
|
export const CAMPAIGN_START = "2026-08-17";
|
||||||
export const CAMPAIGN_DAYS = 56;
|
|
||||||
|
|
||||||
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
||||||
timeZone: "America/New_York",
|
timeZone: "America/New_York",
|
||||||
@@ -130,15 +130,26 @@ export function prettyDate(date: string): string {
|
|||||||
* One calendar day of the plan. Most days teach topics (one, or two when the
|
* One calendar day of the plan. Most days teach topics (one, or two when the
|
||||||
* plan doubles up); the rest are named by what they assign instead: a fixed
|
* plan doubles up); the rest are named by what they assign instead: a fixed
|
||||||
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
|
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
|
||||||
* or a sweep — redo every miss, nothing new. Absent dates are unplanned:
|
* a transfer-phase review day, or a sweep — redo every miss, nothing new.
|
||||||
* the ladder still runs, the digest just has nothing new to say.
|
* Absent dates are unplanned: the ladder still runs, the digest just has
|
||||||
|
* nothing new to say.
|
||||||
*/
|
*/
|
||||||
export interface ScheduledDay {
|
export interface ScheduledDay {
|
||||||
/** Topic issues taught that day, in the order the digest presents them. */
|
/** Topic issues taught that day, in the order the digest presents them. */
|
||||||
topics?: number[];
|
topics?: number[];
|
||||||
/** Problems assigned by number instead of by topic. */
|
/** Problems assigned by number instead of by topic. */
|
||||||
problems?: number[];
|
problems?: number[];
|
||||||
/** Saturday: the blind set, fixed by the plan rather than sampled. */
|
/**
|
||||||
|
* A `Review <N> — <Phase>` day (Mon/Tue/Thu/Fri only): the issue, its title
|
||||||
|
* and its problems in solve order. The issue's sub-issues are the truth; this
|
||||||
|
* copy is what the digest and RESERVED read without calling GitHub, and the
|
||||||
|
* catalog reconcile reports any drift between the two.
|
||||||
|
*/
|
||||||
|
review?: { issue: number; title: string; problems: number[] };
|
||||||
|
/**
|
||||||
|
* Saturday: the blind set, fixed by the plan rather than sampled. An empty
|
||||||
|
* list skips that week's checkpoint — no issue is created.
|
||||||
|
*/
|
||||||
gate?: number[];
|
gate?: number[];
|
||||||
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
||||||
sweep?: boolean;
|
sweep?: boolean;
|
||||||
@@ -146,6 +157,15 @@ export interface ScheduledDay {
|
|||||||
|
|
||||||
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last day the plan books anything — the campaign ends there. Derived,
|
||||||
|
* not declared: booking a later day (the review phase ran the plan past its
|
||||||
|
* original eight weeks) moves the digest's "Day N/M", the stats' `heat` and
|
||||||
|
* the heatmap charts with it, and nothing can disagree about when it ends.
|
||||||
|
*/
|
||||||
|
export const CAMPAIGN_END = Object.keys(SCHEDULE).sort().at(-1)!;
|
||||||
|
export const CAMPAIGN_DAYS = daysBetween(CAMPAIGN_START, CAMPAIGN_END) + 1;
|
||||||
|
|
||||||
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
||||||
export function topicDate(topic: number): string | undefined {
|
export function topicDate(topic: number): string | undefined {
|
||||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||||
@@ -161,13 +181,17 @@ export function topicWeek(topic: number): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every problem the plan names outright — a gate's blind set or a set day's
|
* Every problem the plan names outright — a gate's blind set, a set day's
|
||||||
* list. The plan owns these: the samplers (daily drills, a sampled gate)
|
* list, or a review day's. The plan owns these: the samplers (daily drills,
|
||||||
* never draw them, so a gate problem stays unseen until its Saturday and a
|
* a sampled gate) never draw them, so a gate problem stays unseen until its
|
||||||
* set day's problems wait for their day.
|
* Saturday and a set or review day's problems wait for their day.
|
||||||
*/
|
*/
|
||||||
export const RESERVED: ReadonlySet<number> = new Set(
|
export const RESERVED: ReadonlySet<number> = new Set(
|
||||||
Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]),
|
Object.values(SCHEDULE).flatMap((day) => [
|
||||||
|
...(day.gate ?? []),
|
||||||
|
...(day.problems ?? []),
|
||||||
|
...(day.review?.problems ?? []),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── deterministic sampling ───────────────────────────────────────
|
// ── deterministic sampling ───────────────────────────────────────
|
||||||
|
|||||||
+61
-11
@@ -1,26 +1,34 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Close the GitHub issue for every topic whose required (core) problems are done.
|
* Close the GitHub issue for every topic whose required (core) problems are
|
||||||
|
* done, and for every `Review <N> — <Phase>` issue whose problems all are.
|
||||||
*
|
*
|
||||||
* Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each
|
* Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each
|
||||||
* problem carries exactly one `set:` label — `set:core` is required, while
|
* problem carries exactly one `set:` label — `set:core` is required, while
|
||||||
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
|
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
|
||||||
* every one of its core sub-issues is closed; optional/deferred state is
|
* every one of its core sub-issues is closed; optional/deferred state is
|
||||||
* ignored, matching the day rule in each topic body ("Core problems first").
|
* ignored, matching the day rule in each topic body ("Core problems first").
|
||||||
* The rule lives in topics.ts, shared with release-topics.ts.
|
* Problems re-parented into Review issues still count for their topic. The
|
||||||
|
* rules live in topics.ts, shared with release-topics.ts.
|
||||||
|
*
|
||||||
|
* A Review issue is a fixed day's assignment, so it closes when every one of
|
||||||
|
* its sub-issues is closed, whatever their set. Closing it fires the Worker's
|
||||||
|
* `review`-label webhook, which scores only issues with a gate record — these
|
||||||
|
* have none, so that is a no-op.
|
||||||
*
|
*
|
||||||
* Like close-solved.ts this reconciles state instead of reacting to an event
|
* Like close-solved.ts this reconciles state instead of reacting to an event
|
||||||
* payload: re-runs are no-ops, backfilling needs no special casing, and only
|
* payload: re-runs are no-ops, backfilling needs no special casing, and only
|
||||||
* open topics are touched, so reopening a problem never reopens its topic.
|
* open topics/reviews are touched, so reopening a problem never reopens either.
|
||||||
*
|
*
|
||||||
* bun apps/cli/close-topics.ts # close finished topics
|
* bun apps/cli/close-topics.ts # close finished topics and reviews
|
||||||
* bun apps/cli/close-topics.ts --dry-run # report only, touch nothing
|
* bun apps/cli/close-topics.ts --dry-run # report only, touch nothing
|
||||||
*
|
*
|
||||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||||
*/
|
*/
|
||||||
|
import { movedFromTopics, readReviews } from "../api/src/reviews.ts";
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
import { coreSet, readProblems } from "./topics.ts";
|
import { completion, coreSet, readProblems } from "./topics.ts";
|
||||||
|
|
||||||
const DRY =
|
const DRY =
|
||||||
process.argv.includes("--dry-run") ||
|
process.argv.includes("--dry-run") ||
|
||||||
@@ -55,13 +63,16 @@ for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=ope
|
|||||||
}
|
}
|
||||||
topics.sort((a, b) => a.number - b.number);
|
topics.sort((a, b) => a.number - b.number);
|
||||||
|
|
||||||
|
const reviews = await readReviews(gh);
|
||||||
|
const moved = movedFromTopics(reviews);
|
||||||
|
|
||||||
// ── reconcile ────────────────────────────────────────────────────
|
// ── reconcile ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const report: string[][] = [];
|
const report: string[][] = [];
|
||||||
let closed = 0;
|
let closed = 0;
|
||||||
|
|
||||||
for (const topic of topics) {
|
for (const topic of topics) {
|
||||||
const core = coreSet(await readProblems(gh, topic.number));
|
const core = coreSet(await readProblems(gh, topic.number, moved));
|
||||||
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
|
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
|
||||||
|
|
||||||
// A topic with no core sub-issues has nothing to complete: never close it,
|
// A topic with no core sub-issues has nothing to complete: never close it,
|
||||||
@@ -95,19 +106,58 @@ for (const topic of topics) {
|
|||||||
closed++;
|
closed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── reviews ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const reviewReport: string[][] = [];
|
||||||
|
let reviewsClosed = 0;
|
||||||
|
|
||||||
|
for (const review of reviews.filter((r) => r.open)) {
|
||||||
|
const set = completion(review.problems);
|
||||||
|
const progress = `${set.all.length - set.pending.length}/${set.all.length}`;
|
||||||
|
// An empty review would read as done; leave it for a human, like a topic
|
||||||
|
// with no core set.
|
||||||
|
if (set.all.length === 0) {
|
||||||
|
reviewReport.push([`#${review.number}`, review.title, progress, "no sub-issues"]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (set.pending.length > 0) {
|
||||||
|
reviewReport.push([`#${review.number}`, review.title, progress, `open: ${set.pending.map((n) => `#${n}`).join(" ")}`]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (DRY) {
|
||||||
|
reviewReport.push([`#${review.number}`, review.title, progress, "would close"]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await gh.closeIssue(
|
||||||
|
review.number,
|
||||||
|
`Review complete — all ${set.all.length} problems closed ` +
|
||||||
|
`(${set.all.map((n) => `#${n}`).join(", ")}).\n\n` +
|
||||||
|
"Closed automatically by `close-topics`.",
|
||||||
|
);
|
||||||
|
reviewReport.push([`#${review.number}`, review.title, progress, "closed"]);
|
||||||
|
reviewsClosed++;
|
||||||
|
}
|
||||||
|
|
||||||
// ── report ───────────────────────────────────────────────────────
|
// ── report ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const rows = [["topic", "title", "core", "status"], ...report];
|
const rows = [["topic", "title", "core", "status"], ...report];
|
||||||
printTable(rows);
|
printTable(rows);
|
||||||
|
const reviewRows = [["review", "title", "closed", "status"], ...reviewReport];
|
||||||
|
console.log();
|
||||||
|
printTable(reviewRows);
|
||||||
|
|
||||||
const finished = report.filter((r) => r[3] === "closed" || r[3] === "would close");
|
const finished = new Set(["closed", "would close"]);
|
||||||
|
const topicsDone = report.filter((r) => finished.has(r[3]!)).length;
|
||||||
|
const reviewsDone = reviewReport.filter((r) => finished.has(r[3]!)).length;
|
||||||
console.log(
|
console.log(
|
||||||
`\n${topics.length} open topics · ` +
|
`\n${topics.length} open topics · ${DRY ? `would close ${topicsDone}` : `closed ${closed}`}` +
|
||||||
`${DRY ? `would close ${finished.length}` : `closed ${closed}`}`,
|
`\n${reviewReport.length} open reviews · ${DRY ? `would close ${reviewsDone}` : `closed ${reviewsClosed}`}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await writeStepSummary(
|
await writeStepSummary(
|
||||||
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
|
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
`${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` +
|
`${topics.length} open topic issues, ${topicsDone} with a complete core set.\n\n` +
|
||||||
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n"),
|
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n") +
|
||||||
|
`\n${reviewReport.length} open review issues, ${reviewsDone} with every problem closed.\n\n` +
|
||||||
|
(reviewReport.length ? `${markdownTable(reviewRows)}\n` : "No open reviews.\n"),
|
||||||
);
|
);
|
||||||
+103
-66
@@ -1,32 +1,40 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Publish one GitHub release per finished topic, versioned by timever
|
* Publish one GitHub release per finished topic and per finished
|
||||||
|
* `Review <N> — <Phase>` issue, versioned by timever
|
||||||
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668).
|
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668).
|
||||||
*
|
*
|
||||||
* "Finished" is topics.ts's rule — every core sub-issue closed — the same one
|
* "Finished" is topics.ts's rule — a topic when every core problem is closed,
|
||||||
* close-topics.ts closes the topic issue on, and the version is the instant
|
* a review when every one of its sub-issues is — the same one close-topics.ts
|
||||||
* the LAST core problem closed. Keying off the sub-issues rather than the
|
* closes the issue on, and the version is the instant the LAST required
|
||||||
* topic issue lets this run as the tail of Close Solved: the push that closes
|
* problem closed. Keying off the sub-issues rather than the topic/review issue
|
||||||
* a topic's final core problem releases it in the same run, without waiting
|
* lets this run as the tail of Close Solved: the push that closes the final
|
||||||
* for close-topics to chain after it. The topic issue itself closes seconds
|
* required problem releases it in the same run, without waiting for
|
||||||
* later, on the same day.
|
* close-topics to chain after it. The issue itself closes seconds later, on
|
||||||
|
* the same day.
|
||||||
*
|
*
|
||||||
* Each release is a changelog snapshot at that instant: the topic's problem
|
* Each release is a changelog snapshot at that instant: the issue's problems
|
||||||
* sub-issues grouped as closed on the finish day (ET), closed earlier, and
|
* that had closed by then (a topic's sub-issues plus those since re-parented
|
||||||
* open at release, each with its set, difficulty, and the solution files as
|
* into Review issues, or a review's sub-issues in solve order — see
|
||||||
* they stood in the tagged commit — the last main commit before the finish.
|
* topics.ts), grouped as closed on the finish day (ET) and closed earlier,
|
||||||
|
* each with its set, difficulty, and the solution files as they stood in the
|
||||||
|
* tagged commit — the last main commit before the finish. Problems still open
|
||||||
|
* at the finish are left out: the release records what finished, and an open
|
||||||
|
* optional problem is tracked by its issue (or its Review day), not here.
|
||||||
|
* The docs site (prdlk/cs `lib/releases.ts`) reads these `### Closed …`
|
||||||
|
* tables back for its `/changelog` timeline, so their row shape is its contract.
|
||||||
*
|
*
|
||||||
* The tag is annotated and backdated (tagger date = finish), so
|
* The tag is annotated and backdated (tagger date = finish), so
|
||||||
* `git tag --sort=taggerdate` reads as the campaign's history even for tags
|
* `git tag --sort=taggerdate` reads as the campaign's history even for tags
|
||||||
* minted by a backfill. The release's own published date cannot be backdated
|
* minted by a backfill. The release's own published date cannot be backdated
|
||||||
* (the API has no field for it), so the docs changelog dates each entry from
|
* (the API has no field for it), so the docs changelog dates each entry from
|
||||||
* this timever tag instead (`releasesDatedByTag` in apps/docs/blume.config.ts)
|
* this timever tag instead (prdlk/cs `lib/releases.ts`)
|
||||||
* — the tag format is that contract.
|
* — the tag format is that contract.
|
||||||
*
|
*
|
||||||
* Reconciles instead of reacting: every finished topic without a release gets
|
* Reconciles instead of reacting: every finished topic or review without a
|
||||||
* one, so a first run backfills the whole campaign; an existing release keeps
|
* release gets one, so a first run backfills the whole campaign; an existing
|
||||||
* its tag and commit and only has its body rewritten when render() would say
|
* release keeps its tag and commit and only has its body rewritten when
|
||||||
* something else. Re-runs with nothing new are no-ops.
|
* render() would say something else. Re-runs with nothing new are no-ops.
|
||||||
* A release is found by the marker comment in its body, never by tag name, so
|
* A release is found by the marker comment in its body, never by tag name, so
|
||||||
* reopening and re-closing a core problem can never mint a second release.
|
* reopening and re-closing a core problem can never mint a second release.
|
||||||
* A tag that exists without its release (a run that died between the two) is
|
* A tag that exists without its release (a run that died between the two) is
|
||||||
@@ -40,9 +48,10 @@
|
|||||||
* committer identity (the tagger).
|
* committer identity (the tagger).
|
||||||
*/
|
*/
|
||||||
import { etDate } from "../api/src/srs.ts";
|
import { etDate } from "../api/src/srs.ts";
|
||||||
|
import { movedFromTopics, readReviews, type Problem } from "../api/src/reviews.ts";
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
import { coreSet, labelValue, readProblems, type Problem } from "./topics.ts";
|
import { completion, coreSet, labelValue, readProblems } from "./topics.ts";
|
||||||
import { ROOT, parseWorkPath } from "./work.ts";
|
import { ROOT, parseWorkPath } from "./work.ts";
|
||||||
|
|
||||||
const DRY =
|
const DRY =
|
||||||
@@ -50,7 +59,7 @@ const DRY =
|
|||||||
process.env.DRY_RUN === "1" ||
|
process.env.DRY_RUN === "1" ||
|
||||||
process.env.DRY_RUN === "true";
|
process.env.DRY_RUN === "true";
|
||||||
|
|
||||||
/** The docs site (apps/docs/blume.config.ts `deployment.site`); sync publishes `/<num>` short links. */
|
/** The docs site (prdlk/cs `blume.config.ts` `deployment.site`); it publishes sync's `/<num>` short links. */
|
||||||
const DOCS_SITE = "https://lc.prd.lk";
|
const DOCS_SITE = "https://lc.prd.lk";
|
||||||
const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668";
|
const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668";
|
||||||
|
|
||||||
@@ -62,9 +71,10 @@ function git(...args: string[]): string {
|
|||||||
return run.stdout.toString().trim();
|
return run.stdout.toString().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── topics ───────────────────────────────────────────────────────
|
// ── finished topics and reviews ──────────────────────────────────
|
||||||
|
|
||||||
interface Topic {
|
/** A topic or Review issue, as far as its release reads it. */
|
||||||
|
interface Issue {
|
||||||
number: number;
|
number: number;
|
||||||
title: string;
|
title: string;
|
||||||
milestone: string | null;
|
milestone: string | null;
|
||||||
@@ -72,7 +82,7 @@ interface Topic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */
|
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */
|
||||||
function readTopic(value: unknown): Topic | undefined {
|
function readTopic(value: unknown): Issue | undefined {
|
||||||
if (!value || typeof value !== "object") return;
|
if (!value || typeof value !== "object") return;
|
||||||
if ("pull_request" in value) return;
|
if ("pull_request" in value) return;
|
||||||
if (!("number" in value) || typeof value.number !== "number") return;
|
if (!("number" in value) || typeof value.number !== "number") return;
|
||||||
@@ -90,28 +100,46 @@ function readTopic(value: unknown): Topic | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Finished {
|
interface Finished {
|
||||||
topic: Topic;
|
kind: "topic" | "review";
|
||||||
|
issue: Issue;
|
||||||
|
/** Topic: every problem it owns, by number. Review: its sub-issues, in solve order. */
|
||||||
problems: Problem[];
|
problems: Problem[];
|
||||||
core: number[];
|
/** The set whose last close finished it: a topic's core, a review's every problem. */
|
||||||
|
required: number[];
|
||||||
finishedAt: string;
|
finishedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closed and open topics alike: the rule reads the sub-issues, so a topic
|
// Closed and open issues alike: the rules read the sub-issues, so a topic or
|
||||||
// finished moments ago (close-topics not yet run) releases now.
|
// review finished moments ago (close-topics not yet run) releases now.
|
||||||
const finished: Finished[] = [];
|
const finished: Finished[] = [];
|
||||||
|
const reviews = await readReviews(gh);
|
||||||
|
const moved = movedFromTopics(reviews);
|
||||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||||
const topic = readTopic(raw);
|
const issue = readTopic(raw);
|
||||||
if (!topic) continue;
|
if (!issue) continue;
|
||||||
const problems = await readProblems(gh, topic.number);
|
const problems = await readProblems(gh, issue.number, moved);
|
||||||
const core = coreSet(problems);
|
const core = coreSet(problems);
|
||||||
if (core.finishedAt) finished.push({ topic, problems, core: core.all, finishedAt: core.finishedAt });
|
if (core.finishedAt) finished.push({ kind: "topic", issue, problems, required: core.all, finishedAt: core.finishedAt });
|
||||||
|
}
|
||||||
|
for (const review of reviews) {
|
||||||
|
const set = completion(review.problems);
|
||||||
|
if (!set.finishedAt) continue;
|
||||||
|
finished.push({
|
||||||
|
kind: "review",
|
||||||
|
// The review's phase is its body's `**Phase:**` line; its milestone is the
|
||||||
|
// shared "Review — Transfer & Mocks" and says nothing per issue.
|
||||||
|
issue: { number: review.number, title: review.title, milestone: null, body: review.body },
|
||||||
|
problems: review.problems,
|
||||||
|
required: set.all,
|
||||||
|
finishedAt: set.finishedAt,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Oldest first, so a backfill creates releases in campaign order.
|
// Oldest first, so a backfill creates releases in campaign order.
|
||||||
finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.topic.number - b.topic.number);
|
finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.issue.number - b.issue.number);
|
||||||
|
|
||||||
// ── existing releases ────────────────────────────────────────────
|
// ── existing releases ────────────────────────────────────────────
|
||||||
|
|
||||||
/** Topic -> its release. Name and body are kept in sync with the render on every run. */
|
/** Topic or review issue -> its release. Name and body are kept in sync with the render on every run. */
|
||||||
const released = new Map<number, { id: number; tag: string; name: string; body: string }>();
|
const released = new Map<number, { id: number; tag: string; name: string; body: string }>();
|
||||||
for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) {
|
for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) {
|
||||||
if (!raw || typeof raw !== "object") continue;
|
if (!raw || typeof raw !== "object") continue;
|
||||||
@@ -127,10 +155,11 @@ for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Timever of an instant. The API reports whole seconds, so SUBTIME is the
|
* Timever of an instant. The API reports whole seconds, so SUBTIME is the
|
||||||
* tiebreak: two topics finishing in the same second (one push closing both
|
* tiebreak: two topics or reviews finishing in the same second (one push
|
||||||
* last core problems) get consecutive nanoseconds, in (instant, topic) order.
|
* closing both last problems) get consecutive nanoseconds, in (instant,
|
||||||
|
* issue) order.
|
||||||
*/
|
*/
|
||||||
const tags = new Map<number, string>(); // topic -> tag
|
const tags = new Map<number, string>(); // issue -> tag
|
||||||
{
|
{
|
||||||
let prev = "";
|
let prev = "";
|
||||||
let ns = 0;
|
let ns = 0;
|
||||||
@@ -139,7 +168,7 @@ const tags = new Map<number, string>(); // topic -> tag
|
|||||||
const base = `${iso.slice(0, 10).replaceAll("-", "")}.${iso.slice(11, 19).replaceAll(":", "")}`;
|
const base = `${iso.slice(0, 10).replaceAll("-", "")}.${iso.slice(11, 19).replaceAll(":", "")}`;
|
||||||
ns = base === prev ? ns + 1 : Number(iso.slice(20, 23)) * 1_000_000;
|
ns = base === prev ? ns + 1 : Number(iso.slice(20, 23)) * 1_000_000;
|
||||||
prev = base;
|
prev = base;
|
||||||
tags.set(f.topic.number, `${base}.${String(ns).padStart(9, "0")}`);
|
tags.set(f.issue.number, `${base}.${String(ns).padStart(9, "0")}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,13 +203,13 @@ function bodySection(body: string, heading: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render(f: Finished, tag: string, sha: string, files: Map<number, string[]>): string {
|
function render(f: Finished, tag: string, sha: string, files: Map<number, string[]>): string {
|
||||||
const { topic, problems } = f;
|
const { issue, problems } = f;
|
||||||
const finishDay = etDate(new Date(f.finishedAt));
|
const finishDay = etDate(new Date(f.finishedAt));
|
||||||
// Snapshot at the finish instant: a problem closed later was open then.
|
// Snapshot at the finish instant: a problem closed later was open then, and
|
||||||
|
// open problems are not part of the release.
|
||||||
const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt;
|
const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt;
|
||||||
const onDay = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) === finishDay);
|
const onDay = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) === finishDay);
|
||||||
const earlier = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) !== finishDay);
|
const earlier = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) !== finishDay);
|
||||||
const open = problems.filter((p) => !closedBy(p));
|
|
||||||
|
|
||||||
const row = (p: Problem): string[] => {
|
const row = (p: Problem): string[] => {
|
||||||
const lc = Number(p.title.match(/^LC (\d+)/)?.[1]);
|
const lc = Number(p.title.match(/^LC (\d+)/)?.[1]);
|
||||||
@@ -206,27 +235,31 @@ function render(f: Finished, tag: string, sha: string, files: Map<number, string
|
|||||||
// with one self-contained sentence and keep the fields on one `·` line.
|
// with one self-contained sentence and keep the fields on one `·` line.
|
||||||
const when = ET_WHEN.format(new Date(f.finishedAt));
|
const when = ET_WHEN.format(new Date(f.finishedAt));
|
||||||
const summary =
|
const summary =
|
||||||
`Finished ${when} ET: all ${f.core.length} core problems closed, ` +
|
`Finished ${when} ET: all ${f.required.length} ${f.kind === "topic" ? "core " : ""}problems closed, ` +
|
||||||
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
|
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const scheduled = bodyField(topic.body, "Scheduled");
|
const scheduled = bodyField(issue.body, "Scheduled");
|
||||||
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
|
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
|
||||||
if (topic.milestone) fields.push(`**Phase:** ${topic.milestone}`);
|
const phase = f.kind === "topic" ? issue.milestone : bodyField(issue.body, "Phase");
|
||||||
fields.push(`**Topic:** #${topic.number}`);
|
if (phase) fields.push(`**Phase:** ${phase}`);
|
||||||
const buildsOn = bodyField(topic.body, "Builds on");
|
fields.push(`**${f.kind === "topic" ? "Topic" : "Review"}:** #${issue.number}`);
|
||||||
|
const buildsOn = bodyField(issue.body, "Builds on");
|
||||||
if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`);
|
if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`);
|
||||||
|
const load = bodyField(issue.body, "Load");
|
||||||
|
if (load) fields.push(`**Load:** ${load}`);
|
||||||
// The marker is how a later run finds this release; see the file header.
|
// The marker is how a later run finds this release; see the file header.
|
||||||
const sections = [`<!-- release-topics: #${topic.number} -->\n${summary}`, fields.join(" · ")];
|
// One marker for both kinds: issue numbers never collide.
|
||||||
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the topic finished\n\n${table(onDay)}`);
|
const sections = [`<!-- release-topics: #${issue.number} -->\n${summary}`, fields.join(" · ")];
|
||||||
|
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the ${f.kind} finished\n\n${table(onDay)}`);
|
||||||
if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
|
if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
|
||||||
if (open.length) sections.push(`### Open at release\n\n${table(open)}`);
|
|
||||||
const notes = NOTES.flatMap((h) => {
|
const notes = NOTES.flatMap((h) => {
|
||||||
const text = bodySection(topic.body, h);
|
const text = bodySection(issue.body, h);
|
||||||
return text ? [`**${h}.** ${text}`] : [];
|
return text ? [`**${h}.** ${text}`] : [];
|
||||||
});
|
});
|
||||||
if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`);
|
if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`);
|
||||||
sections.push(
|
sections.push(
|
||||||
`---\n\n\`${tag}\` is [timever](${TIMEVER}) for the UTC instant the core set completed; ` +
|
`---\n\n\`${tag}\` is [timever](${TIMEVER}) for the UTC instant the ` +
|
||||||
|
`${f.kind === "topic" ? "core set completed" : "last problem closed"}; ` +
|
||||||
`it tags ${sha.slice(0, 7)}, the last main commit before it.`,
|
`it tags ${sha.slice(0, 7)}, the last main commit before it.`,
|
||||||
);
|
);
|
||||||
return `${sections.join("\n\n")}\n`;
|
return `${sections.join("\n\n")}\n`;
|
||||||
@@ -234,20 +267,20 @@ function render(f: Finished, tag: string, sha: string, files: Map<number, string
|
|||||||
|
|
||||||
// ── reconcile ────────────────────────────────────────────────────
|
// ── reconcile ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const latest = finished.at(-1)?.topic.number;
|
const latest = finished.at(-1)?.issue.number;
|
||||||
const report: string[][] = [];
|
const report: string[][] = [];
|
||||||
const previews: string[] = [];
|
const previews: string[] = [];
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
|
||||||
for (const f of finished) {
|
for (const f of finished) {
|
||||||
const { topic } = f;
|
const { issue } = f;
|
||||||
const finishedEt = etDate(new Date(f.finishedAt));
|
const finishedEt = etDate(new Date(f.finishedAt));
|
||||||
const existing = released.get(topic.number);
|
const existing = released.get(issue.number);
|
||||||
// An existing release keeps its tag and commit; only its name and body are
|
// An existing release keeps its tag and commit; only its name and body are
|
||||||
// reconciled. Its tag may postdate this clone (released by another run), so
|
// reconciled. Its tag may postdate this clone (released by another run), so
|
||||||
// fetch it when missing — read-only, so a dry run does it too.
|
// fetch it when missing — read-only, so a dry run does it too.
|
||||||
const tag = existing?.tag ?? tags.get(topic.number)!;
|
const tag = existing?.tag ?? tags.get(issue.number)!;
|
||||||
if (existing && Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT }).exitCode !== 0) {
|
if (existing && Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT }).exitCode !== 0) {
|
||||||
git("fetch", "-q", "origin", `refs/tags/${tag}:refs/tags/${tag}`);
|
git("fetch", "-q", "origin", `refs/tags/${tag}:refs/tags/${tag}`);
|
||||||
}
|
}
|
||||||
@@ -255,7 +288,7 @@ for (const f of finished) {
|
|||||||
? git("rev-parse", `${tag}^{commit}`)
|
? git("rev-parse", `${tag}^{commit}`)
|
||||||
: git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD");
|
: git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD");
|
||||||
if (!sha) {
|
if (!sha) {
|
||||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "ERROR: no commit before finish"]);
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "ERROR: no commit before finish"]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// LC number -> its first-solve files in the tagged tree. Keyed by file name,
|
// LC number -> its first-solve files in the tagged tree. Keyed by file name,
|
||||||
@@ -269,20 +302,20 @@ for (const f of finished) {
|
|||||||
files.set(Number(lc), [...(files.get(Number(lc)) ?? []), path]);
|
files.set(Number(lc), [...(files.get(Number(lc)) ?? []), path]);
|
||||||
}
|
}
|
||||||
const body = render(f, tag, sha, files);
|
const body = render(f, tag, sha, files);
|
||||||
const name = topic.title;
|
const name = issue.title;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.body === body && existing.name === name) {
|
if (existing.body === body && existing.name === name) {
|
||||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released"]);
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released"]);
|
||||||
} else if (DRY) {
|
} else if (DRY) {
|
||||||
previews.push(`## ${name} (${tag})\n\n${body}`);
|
previews.push(`## ${name} (${tag})\n\n${body}`);
|
||||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would update"]);
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would update"]);
|
||||||
} else {
|
} else {
|
||||||
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
|
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ name, body }),
|
body: JSON.stringify({ name, body }),
|
||||||
});
|
});
|
||||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "updated"]);
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "updated"]);
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -290,7 +323,7 @@ for (const f of finished) {
|
|||||||
|
|
||||||
if (DRY) {
|
if (DRY) {
|
||||||
previews.push(`## ${name} (${tag})\n\n${body}`);
|
previews.push(`## ${name} (${tag})\n\n${body}`);
|
||||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would release"]);
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would release"]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +332,11 @@ for (const f of finished) {
|
|||||||
const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT });
|
const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT });
|
||||||
if (local.exitCode !== 0) {
|
if (local.exitCode !== 0) {
|
||||||
const tagged = Bun.spawnSync(
|
const tagged = Bun.spawnSync(
|
||||||
["git", "tag", "-a", tag, sha, "-m", `${topic.title} (#${topic.number})\n\nCore set complete: ${f.core.map((n) => `#${n}`).join(", ")}`],
|
[
|
||||||
|
"git", "tag", "-a", tag, sha, "-m",
|
||||||
|
`${issue.title} (#${issue.number})\n\n` +
|
||||||
|
`${f.kind === "topic" ? "Core set complete" : "All problems closed"}: ${f.required.map((n) => `#${n}`).join(", ")}`,
|
||||||
|
],
|
||||||
{ cwd: ROOT, env: { ...process.env, GIT_COMMITTER_DATE: f.finishedAt } },
|
{ cwd: ROOT, env: { ...process.env, GIT_COMMITTER_DATE: f.finishedAt } },
|
||||||
);
|
);
|
||||||
if (tagged.exitCode !== 0) throw new Error(`git tag ${tag}: ${tagged.stderr.toString().trim()}`);
|
if (tagged.exitCode !== 0) throw new Error(`git tag ${tag}: ${tagged.stderr.toString().trim()}`);
|
||||||
@@ -312,21 +349,21 @@ for (const f of finished) {
|
|||||||
tag_name: tag,
|
tag_name: tag,
|
||||||
name,
|
name,
|
||||||
body,
|
body,
|
||||||
make_latest: topic.number === latest ? "true" : "false",
|
make_latest: issue.number === latest ? "true" : "false",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released now"]);
|
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released now"]);
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── report ───────────────────────────────────────────────────────
|
// ── report ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (previews.length) console.log(`${previews.join("\n")}\n`);
|
if (previews.length) console.log(`${previews.join("\n")}\n`);
|
||||||
const rows = [["topic", "title", "finished (ET)", "tag", "status"], ...report];
|
const rows = [["issue", "title", "finished (ET)", "tag", "status"], ...report];
|
||||||
printTable(rows);
|
printTable(rows);
|
||||||
const would = (status: string) => report.filter((r) => r[4] === status).length;
|
const would = (status: string) => report.filter((r) => r[4] === status).length;
|
||||||
console.log(
|
console.log(
|
||||||
`\n${finished.length} finished topics · ` +
|
`\n${finished.length} finished topics and reviews · ` +
|
||||||
(DRY
|
(DRY
|
||||||
? `would release ${would("would release")}, update ${would("would update")}`
|
? `would release ${would("would release")}, update ${would("would update")}`
|
||||||
: `released ${created}, updated ${updated}`),
|
: `released ${created}, updated ${updated}`),
|
||||||
@@ -334,7 +371,7 @@ console.log(
|
|||||||
|
|
||||||
await writeStepSummary(
|
await writeStepSummary(
|
||||||
`### release-topics${DRY ? " (dry run)" : ""}\n\n` +
|
`### release-topics${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
`${finished.length} finished topics, ${released.size} already released.\n\n` +
|
`${finished.length} finished topics and reviews, ${released.size} already released.\n\n` +
|
||||||
(report.length ? `${markdownTable(rows)}\n` : "No finished topics.\n") +
|
(report.length ? `${markdownTable(rows)}\n` : "Nothing finished.\n") +
|
||||||
(previews.length ? `\n${previews.join("\n")}` : ""),
|
(previews.length ? `\n${previews.join("\n")}` : ""),
|
||||||
);
|
);
|
||||||
+141
-206
@@ -1,6 +1,13 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Sync work/1 leetcode solutions into apps/docs/content/ pages.
|
* Sync work/1 leetcode solutions into docs/ pages, plus docs/site.json.
|
||||||
|
*
|
||||||
|
* docs/ is content only — MDX pages and one generated JSON manifest, no Blume
|
||||||
|
* config. The docs site itself (config, components, islands, the Appendix)
|
||||||
|
* lives in prdlk/cs and reads docs/ over HTTP with Blume's `mdxRemote()`
|
||||||
|
* source, which fetches `.md`/`.mdx` and nothing else: no `meta.ts`. So the
|
||||||
|
* sidebar meta a folder `meta.ts` used to carry, and the `/<num>` short links,
|
||||||
|
* travel in `docs/site.json` instead, which the site fetches at the same ref.
|
||||||
*
|
*
|
||||||
* - New problems get a full page (frontmatter, warning, examples,
|
* - New problems get a full page (frontmatter, warning, examples,
|
||||||
* constraints, solution) in the gold-standard format.
|
* constraints, solution) in the gold-standard format.
|
||||||
@@ -15,56 +22,60 @@
|
|||||||
* `topicTags` once — when the page is created, or on any later run that
|
* `topicTags` once — when the page is created, or on any later run that
|
||||||
* finds the key missing — and never refreshed after, so a sync with nothing
|
* finds the key missing — and never refreshed after, so a sync with nothing
|
||||||
* new makes no request. Delete the key to refetch it. The page itself
|
* new makes no request. Delete the key to refetch it. The page itself
|
||||||
* renders no badges: the docs MDX `wrapper`
|
* renders no badges: the site's MDX `wrapper` (prdlk/cs
|
||||||
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
|
* `components/ProblemBadges.astro`) turns `topics:` and the `video:` key
|
||||||
* `video:` key `videos.ts` writes into the badge row under the description.
|
* `videos.ts` writes into the badge row under the description.
|
||||||
* - Every tag a page carries that no campaign topic teaches (not a key of
|
* - Every planned page (a problem the README plan lists) carries its GitHub
|
||||||
* `apps/docs/topic-landings.ts`) gets a placeholder guide page,
|
* problem issue as frontmatter `issue:` (the URL of the `problem`-labelled
|
||||||
* `guides/<slug>.mdx`, with `list:` pointing at LeetCode's problem list for
|
* issue titled `LC <num> · …`), rendered by the same wrapper as a GitHub
|
||||||
* the tag. Created once (a missing or empty file), then hand-owned except
|
* badge after the others; an unplanned warm-up has no catalog issue and no
|
||||||
* for `sidebar.badge`, which sync keeps at the guide's backlink count (the
|
* key. Like `topics:` it is filled once — on create, or on any run that
|
||||||
* problem pages carrying the tag; dropped at zero); the tag's badge on a
|
* finds it missing — and the issue list is fetched only on such a run
|
||||||
* problem page links to it. A guide may be filed into a category `(group)`
|
* (never on a sync with nothing new); offline, or with no `gh` auth, the
|
||||||
* folder (e.g. `guides/(2-algorithms)/`) — no route segment, so
|
* page is written without it and the next sync retries. Delete the key to
|
||||||
* `/guides/<slug>` holds — and is found there by slug; a new placeholder
|
* refetch it.
|
||||||
* lands at the `guides/` root for a human to file (and give an `icon:`,
|
|
||||||
* which every badge linking to the guide shows; sync warns while one is
|
|
||||||
* missing or shared).
|
|
||||||
*
|
*
|
||||||
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`,
|
* 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
|
* A problem outside the plan (warm-ups) has no topic, so it groups under the
|
||||||
* plan (warm-ups) has no topic, so it groups under the data-structure CATEGORY
|
* data-structure CATEGORY its file sits under in
|
||||||
* its file sits under in `work/1/<Difficulty>/<Category>/` instead.
|
* `work/1/<Difficulty>/<Category>/` instead.
|
||||||
*
|
*
|
||||||
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
|
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
|
||||||
* The phase folder is a Blume `(group)` — a sidebar section adding no route
|
* The phase folder is a Blume `(group)` — a sidebar section adding no route
|
||||||
* segment — while the topic folder is a real segment
|
* segment — while the topic folder is a real segment
|
||||||
* (`/tree-dfs/invert-binary-tree`), so a hand-written `index.mdx` in it is the
|
* (`/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
|
* 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
|
* it. The topic's problem pages sit one level down in a `(problems)` group,
|
||||||
* that still holds one. A phase folder's `meta.ts` is written once, when
|
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either,
|
||||||
* missing, then hand-owned (its title is yours to rename); a topic's is
|
* and Blume strips the `<num>_` prefix from the route while sorting by it, so
|
||||||
* script-owned. An unplanned category group has no phase and sits at the
|
* the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves
|
||||||
* content root.
|
* an `index.mdx`, and never removes a folder that still holds one. An
|
||||||
|
* unplanned category group has no phase and sits at the docs/ root.
|
||||||
|
*
|
||||||
|
* `docs/site.json` is wholly script-owned and rewritten whenever it differs:
|
||||||
|
* - `folders`: Blume folder meta keyed by folder path under docs/ — exactly
|
||||||
|
* the key a `meta.ts` in that folder would have. A phase is `N. Name` in
|
||||||
|
* phase order; a topic is its exact name (the folder slug loses `/` and
|
||||||
|
* case), curriculum order, `TOPIC_ICONS` icon and `display: "page"`, so it
|
||||||
|
* is one sidebar row drilling into its own panel. `(problems)` has none:
|
||||||
|
* Blume's humanized "Problems" is the label.
|
||||||
|
* - `redirects`: one `{ from: "/<num>", to: "/<topic>/<slug>" }` per page, in
|
||||||
|
* number order — exact root-relative paths; Blume prepends the deployment
|
||||||
|
* base itself.
|
||||||
*
|
*
|
||||||
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
|
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
|
||||||
* of problems whose page already exists (see work.ts), and a fresh scaffold
|
* of problems whose page already exists (see work.ts), and a fresh scaffold
|
||||||
* there would otherwise wipe the page's `## Solution`.
|
* there would otherwise wipe the page's `## Solution`.
|
||||||
*
|
|
||||||
* Every page also gets a short link: `/<num>` → `/<topic>/<slug>` (so
|
|
||||||
* `/leetcode/217` opens Contains Duplicate). The rules live in the generated
|
|
||||||
* `apps/docs/redirects.ts`, which `blume.config.ts` imports — Blume takes exact
|
|
||||||
* root-relative paths and prepends the deployment base itself.
|
|
||||||
*/
|
*/
|
||||||
import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
import { mkdir, readdir, rename, rmdir } from "node:fs/promises";
|
||||||
import { basename, dirname, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
|
|
||||||
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
|
import { github } from "./github.ts";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
const WORK = join(ROOT, "work", "1");
|
const WORK = join(ROOT, "work", "1");
|
||||||
const DOCS = join(ROOT, "apps", "docs", "content");
|
const DOCS = join(ROOT, "docs");
|
||||||
const REDIRECTS = join(ROOT, "apps", "docs", "redirects.ts");
|
const MANIFEST = join(DOCS, "site.json");
|
||||||
|
|
||||||
type Lang = "js" | "py";
|
type Lang = "js" | "py";
|
||||||
|
|
||||||
@@ -318,31 +329,34 @@ function phaseDir(phase: Phase): string {
|
|||||||
return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`;
|
return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A sidebar group's Blume folder meta, as `docs/site.json` carries it. */
|
||||||
* Blume `(group)` meta for a phase: the overview page's heading, in phase
|
interface FolderMeta {
|
||||||
* order. Written only when the folder has none — the title is hand-owned after
|
title: string;
|
||||||
* that. Nothing is lost by never rewriting it: the order and name it is built
|
order: number;
|
||||||
* from are also the folder name (`phaseDir`), so a README change to either
|
icon?: string;
|
||||||
* lands in a new folder that gets a fresh meta.
|
display?: "page";
|
||||||
*/
|
|
||||||
function phaseMeta(phase: Phase): string {
|
|
||||||
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(`Phase ${phase.order}: ${phase.name}`)}, order: ${phase.order} });\n`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blume folder meta for a topic: exact label (the folder slug loses `/` and
|
* Folder meta for a phase `(group)`: the overview page's heading, `N. Name`,
|
||||||
* case), curriculum order, the sidebar icon, and `display: "group"` so the
|
* in phase order. Derived like everything in the manifest, so a README rename
|
||||||
* topic collapses under its phase heading. An unplanned category group
|
* renames the heading — and lands in a new folder (`phaseDir`) anyway.
|
||||||
* (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 {
|
function phaseMeta(phase: Phase): FolderMeta {
|
||||||
|
return { title: `${phase.order}. ${phase.name}`, order: phase.order };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folder meta for a topic: exact label, curriculum order, the sidebar icon,
|
||||||
|
* and `display: "page"` so the topic is one row under its phase heading that
|
||||||
|
* drills into its own panel (the landing page, then its `(problems)` list).
|
||||||
|
* An unplanned category group (warm-ups) has no README number and sorts at 0,
|
||||||
|
* ahead of phase 1, so the sidebar opens on the basics.
|
||||||
|
*/
|
||||||
|
function groupMeta(topic: Topic): FolderMeta {
|
||||||
const icon = TOPIC_ICONS[topic.name];
|
const icon = TOPIC_ICONS[topic.name];
|
||||||
if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`);
|
if (!icon) 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}`];
|
return { title: topic.name, order: topic.order ?? 0, ...(icon ? { icon } : {}), display: "page" };
|
||||||
if (icon) fields.push(`icon: ${JSON.stringify(icon)}`);
|
|
||||||
fields.push(`display: "group"`);
|
|
||||||
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ ${fields.join(", ")} });\n`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── LeetCode topic tags ──────────────────────────────────────────
|
// ── LeetCode topic tags ──────────────────────────────────────────
|
||||||
@@ -389,58 +403,49 @@ function topicsYaml(tags: TopicTag[]): string {
|
|||||||
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. */
|
// ── GitHub problem issues ────────────────────────────────────────
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── guides ───────────────────────────────────────────────────────
|
let issueUrls: Promise<Map<number, string> | undefined> | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Placeholder for a tag no campaign topic teaches: the `/guides/<slug>` page a
|
* LC number → the URL of its `problem` issue (`LC <num> · <Name>`, open or
|
||||||
* problem's topic badge links to. `list:` is the tag's LeetCode problem list,
|
* closed), listed once per run and only when a page first needs it — or
|
||||||
* rendered as a badge by the docs MDX `wrapper`. Written once — the page
|
* undefined, warned, when GitHub cannot answer (offline commit, no `gh`
|
||||||
* is hand-owned from then on.
|
* auth): pages go without `issue:` and the next sync fills it in.
|
||||||
*/
|
*/
|
||||||
function guidePage(tag: TopicTag): string {
|
function problemIssues(): Promise<Map<number, string> | undefined> {
|
||||||
return [
|
issueUrls ??= (async () => {
|
||||||
"---",
|
try {
|
||||||
`title: ${JSON.stringify(tag.name)}`,
|
const gh = await github();
|
||||||
`list: ${JSON.stringify(`https://leetcode.com/problem-list/${tag.slug}/`)}`,
|
const urls = new Map<number, string>();
|
||||||
"---",
|
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;
|
||||||
":::note",
|
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||||
`This guide has not been written yet. The LeetCode list above has every problem tagged ${tag.name}.`,
|
if (!("html_url" in raw) || typeof raw.html_url !== "string") continue;
|
||||||
":::",
|
const lc = raw.title.match(/^LC (\d+) · /)?.[1];
|
||||||
"",
|
if (lc && !urls.has(Number(lc))) urls.set(Number(lc), raw.html_url);
|
||||||
].join("\n");
|
}
|
||||||
|
return urls;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`no problem issues: ${err instanceof Error ? err.message : err}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return issueUrls;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Folder meta for `guides/`: its own header tab (`navigation.tabs` in blume.config.ts), so only the label. */
|
|
||||||
const GUIDES_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Guides" });\n`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A guide's frontmatter with `sidebar.badge` set to its backlink count — the
|
* The problem's issue URL, or undefined (warned when GitHub answered but has
|
||||||
* one key sync owns on a hand-owned guide — or dropped (with a `sidebar:` left
|
* no such issue). Only a problem the README plan lists has a catalog issue;
|
||||||
* empty) at zero, so the sidebar shows a count only where there is something
|
* an unplanned warm-up never will, so it is not looked up — otherwise its
|
||||||
* to count.
|
* missing key would re-list the issues and re-warn on every sync.
|
||||||
*/
|
*/
|
||||||
function withBacklinkBadge(page: string, count: number): string {
|
async function issueUrl(sol: Solution): Promise<string | undefined> {
|
||||||
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
if (!topics.has(sol.num)) return undefined;
|
||||||
if (!fm) return page;
|
const urls = await problemIssues();
|
||||||
let head = fm[0].replace(/^ {2}badge: .*\n/m, "");
|
const url = urls?.get(sol.num);
|
||||||
if (count > 0) {
|
if (urls && !url) console.warn(`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`);
|
||||||
const line = ` badge: '${count}'`;
|
return url;
|
||||||
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 ────────────────────────────
|
// ── inline-code formatting heuristics ────────────────────────────
|
||||||
@@ -547,7 +552,7 @@ function warningText(h: Header): string {
|
|||||||
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
|
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
|
||||||
const PAGE_ICON = "dot";
|
const PAGE_ICON = "dot";
|
||||||
|
|
||||||
function renderPage(sol: Solution, tags: TopicTag[] | undefined): string {
|
function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string | undefined): string {
|
||||||
const h = sol.header;
|
const h = sol.header;
|
||||||
const ids = identifiers(sol);
|
const ids = identifiers(sol);
|
||||||
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
|
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
|
||||||
@@ -559,6 +564,7 @@ function renderPage(sol: Solution, tags: TopicTag[] | undefined): string {
|
|||||||
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
|
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
|
||||||
`icon: ${PAGE_ICON}`,
|
`icon: ${PAGE_ICON}`,
|
||||||
...(tags ? [topicsYaml(tags)] : []),
|
...(tags ? [topicsYaml(tags)] : []),
|
||||||
|
...(issue ? [`issue: ${JSON.stringify(issue)}`] : []),
|
||||||
"sidebar:",
|
"sidebar:",
|
||||||
` label: '${h.title.replaceAll("'", "''")}'`,
|
` label: '${h.title.replaceAll("'", "''")}'`,
|
||||||
` badge: '${h.difficulty}'`,
|
` badge: '${h.difficulty}'`,
|
||||||
@@ -654,6 +660,15 @@ async function ensureTopics(page: string, sol: Solution): Promise<string> {
|
|||||||
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. */
|
||||||
|
async function ensureIssue(page: string, sol: Solution): Promise<string> {
|
||||||
|
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offset just past the script-owned `## Solution` section: the next top-level
|
* Offset just past the script-owned `## Solution` section: the next top-level
|
||||||
* heading that is not inside a code fence, or end of file. Sections a human
|
* heading that is not inside a code fence, or end of file. Sections a human
|
||||||
@@ -682,28 +697,24 @@ for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).s
|
|||||||
|
|
||||||
const report: [string, string, string][] = [];
|
const report: [string, string, string][] = [];
|
||||||
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
||||||
const groups = new Map<string, Topic>(); // topic folder -> topic, for meta.ts
|
const folders = new Map<string, FolderMeta>(); // folder under docs/ -> its sidebar meta, for site.json
|
||||||
const phases = new Map<string, Phase>(); // phase folder -> phase, for meta.ts
|
|
||||||
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
|
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
|
||||||
const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order
|
const redirects: { from: string; to: string }[] = []; // one per page, 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) {
|
for (const sol of ordered) {
|
||||||
const langs = Object.keys(sol.code).sort().join("+");
|
const langs = Object.keys(sol.code).sort().join("+");
|
||||||
const workRef = `${sol.num}.${sol.slug} (${langs})`;
|
const workRef = `${sol.num}.${sol.slug} (${langs})`;
|
||||||
// (0N-<phase>)/<topic>/ folder (the topic is a real route segment, so its
|
// (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment,
|
||||||
// index.mdx is the landing page) + leetcode-number prefix: numeric sidebar
|
// so its index.mdx is the landing page; `(problems)` adds none) +
|
||||||
// order, prefix stripped from the URL.
|
// leetcode-number prefix: numeric sidebar order, prefix stripped from the URL.
|
||||||
const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category };
|
const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category };
|
||||||
const dir = topicDir(topic);
|
const dir = topicDir(topic);
|
||||||
groups.set(dir, topic);
|
if (!folders.has(dir)) folders.set(dir, groupMeta(topic));
|
||||||
if (topic.phase) phases.set(phaseDir(topic.phase), topic.phase);
|
if (topic.phase) folders.set(phaseDir(topic.phase), phaseMeta(topic.phase));
|
||||||
const name = `${dir}/${sol.num}-${sol.slug}.mdx`;
|
const name = `${dir}/(problems)/${sol.num}_${sol.slug}.mdx`;
|
||||||
const existing = pages.get(sol.num);
|
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) {
|
if (existing) {
|
||||||
const page = await Bun.file(join(DOCS, existing)).text();
|
const page = await Bun.file(join(DOCS, existing)).text();
|
||||||
const at = page.indexOf("## Solution");
|
const at = page.indexOf("## Solution");
|
||||||
@@ -713,7 +724,7 @@ for (const sol of ordered) {
|
|||||||
}
|
}
|
||||||
const tail = page.slice(solutionEnd(page, at));
|
const tail = page.slice(solutionEnd(page, at));
|
||||||
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
|
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
|
||||||
const next = await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol);
|
const next = await ensureIssue(await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol), sol);
|
||||||
const actions: string[] = [];
|
const actions: string[] = [];
|
||||||
if (existing !== name) {
|
if (existing !== name) {
|
||||||
await rename(join(DOCS, existing), join(DOCS, name));
|
await rename(join(DOCS, existing), join(DOCS, name));
|
||||||
@@ -725,117 +736,41 @@ for (const sol of ordered) {
|
|||||||
actions.push("updated");
|
actions.push("updated");
|
||||||
}
|
}
|
||||||
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
||||||
written = next;
|
|
||||||
} else {
|
} else {
|
||||||
const page = renderPage(sol, await fetchTopicTags(sol));
|
await Bun.write(join(DOCS, name), renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol)));
|
||||||
await Bun.write(join(DOCS, name), page);
|
|
||||||
report.push([workRef, name, "created"]);
|
report.push([workRef, name, "created"]);
|
||||||
written = page;
|
|
||||||
}
|
}
|
||||||
for (const tag of frontmatterTopics(written)) {
|
// The route drops the phase `(group)`, `(problems)` and the number prefix.
|
||||||
tags.set(tag.slug, tag);
|
redirects.push({ from: `/${sol.num}`, to: `/${slugify(topic.name)}/${sol.slug}` });
|
||||||
tagged.set(tag.slug, (tagged.get(tag.slug) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
// The route drops the phase `(group)` and the page's number prefix.
|
|
||||||
redirects.push(` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Script-owned short links, one per page; imported by blume.config.ts.
|
// The site's one generated input besides the pages; script-owned, rewritten
|
||||||
|
// only when it differs. Folders sorted by path so a diff reads by location.
|
||||||
{
|
{
|
||||||
const module = [
|
const manifest = `${JSON.stringify(
|
||||||
"// Generated by `bun run sync` (apps/cli/sync.ts) — do not edit.",
|
{ folders: Object.fromEntries([...folders].sort(([a], [b]) => (a < b ? -1 : 1))), redirects },
|
||||||
"// `/leetcode/<num>` → that problem's page; exact paths, base added by Blume.",
|
null,
|
||||||
"export default [",
|
2,
|
||||||
...redirects,
|
)}\n`;
|
||||||
"];",
|
if (!(await Bun.file(MANIFEST).exists()) || (await Bun.file(MANIFEST).text()) !== manifest) {
|
||||||
"",
|
await Bun.write(MANIFEST, manifest);
|
||||||
].join("\n");
|
report.push([`${folders.size} folders, ${redirects.length} redirects`, "docs/site.json", "written"]);
|
||||||
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"]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One guide page per tag that no campaign topic teaches, created when missing
|
// A folder every page left behind is dead: drop it, then the folders above it
|
||||||
// (an empty file counts as missing) and never rewritten. A guide is found by
|
// that emptied too (`(problems)`, its topic, the phase). A folder that still
|
||||||
// slug anywhere under guides/ — a human files it into a category `(group)`,
|
// holds anything (a hand-written index.mdx, another topic) stops the climb.
|
||||||
// which leaves its route alone — and a new one lands at the guides/ 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("guides/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel);
|
|
||||||
for (const tag of guideTags) {
|
|
||||||
const rel = guideFiles.get(tag.slug) ?? `guides/${tag.slug}.mdx`;
|
|
||||||
const file = Bun.file(join(DOCS, rel));
|
|
||||||
if ((await file.exists()) && file.size > 0) continue;
|
|
||||||
await Bun.write(file, guidePage(tag));
|
|
||||||
report.push([`tag ${tag.slug}`, rel, "created"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every guide's sidebar badge is its backlink count: the problem pages whose
|
|
||||||
// tag badge links to it, which is every page carrying the tag, since a guide's
|
|
||||||
// tag has no landing (`tagTarget()`). A guide whose tag a landing has since
|
|
||||||
// claimed has no backlinks left. Counts move with every solved problem, so the
|
|
||||||
// badge is rewritten whenever it differs; the rest of the guide is untouched.
|
|
||||||
// The same pass checks each guide's hand-picked `icon:`, which is also the icon
|
|
||||||
// on every problem-page badge linking to the guide: it must exist, must not be
|
|
||||||
// a topic group's (`TOPIC_ICONS`), so a guide badge never reads as a topic's,
|
|
||||||
// and must not be another guide's.
|
|
||||||
const topicIcons = new Set(Object.values(TOPIC_ICONS));
|
|
||||||
const guideIcons = new Map<string, string>(); // icon -> the guide that took it first
|
|
||||||
for await (const rel of new Bun.Glob("guides/**/*.mdx").scan(DOCS)) {
|
|
||||||
const slug = basename(rel, ".mdx");
|
|
||||||
const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0);
|
|
||||||
const page = await Bun.file(join(DOCS, rel)).text();
|
|
||||||
const icon = page.match(/^---\n[\s\S]*?\n---/)?.[0].match(/^icon: (\S+)$/m)?.[1];
|
|
||||||
if (!icon) console.warn(`${rel}: no icon; its badges render without one — file it and add \`icon:\``);
|
|
||||||
else if (topicIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is a topic's (TOPIC_ICONS); pick another`);
|
|
||||||
else if (guideIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is ${guideIcons.get(icon)}'s too; pick another`);
|
|
||||||
else guideIcons.set(icon, rel);
|
|
||||||
const next = withBacklinkBadge(page, count);
|
|
||||||
if (next === page) continue;
|
|
||||||
await Bun.write(join(DOCS, rel), next);
|
|
||||||
report.push([`${count} backlinks`, rel, "badged"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase metas: created when missing (an empty file counts as missing), never
|
|
||||||
// rewritten, so a renamed phase title survives every sync.
|
|
||||||
for (const [dir, phase] of phases) {
|
|
||||||
const file = Bun.file(join(DOCS, dir, "meta.ts"));
|
|
||||||
if ((await file.exists()) && file.size > 0) continue;
|
|
||||||
await Bun.write(file, phaseMeta(phase));
|
|
||||||
report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Script-owned folder metas — one per live topic folder, plus guides/ —
|
|
||||||
// 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 ? [["guides", "guides", GUIDES_META] as [string, string, string]] : []),
|
|
||||||
];
|
|
||||||
for (const [label, dir, meta] of metas) {
|
|
||||||
const path = join(DOCS, dir, "meta.ts");
|
|
||||||
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta) continue;
|
|
||||||
await Bun.write(path, meta);
|
|
||||||
report.push([label, `${dir}/meta.ts`, "written"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A folder every page left behind is dead: drop its meta and the folder itself,
|
|
||||||
// then the phase folder above it if that emptied too. A folder that still holds
|
|
||||||
// anything else (a hand-written index.mdx, another topic) stops the climb.
|
|
||||||
for (const start of vacated) {
|
for (const start of vacated) {
|
||||||
for (let dir = start; dir !== "."; dir = dirname(dir)) {
|
for (let dir = start; dir !== "."; dir = dirname(dir)) {
|
||||||
if (groups.has(dir) || phases.has(dir)) break;
|
if (folders.has(dir)) break;
|
||||||
let left: string[];
|
let left: string[];
|
||||||
try {
|
try {
|
||||||
left = await readdir(join(DOCS, dir));
|
left = await readdir(join(DOCS, dir));
|
||||||
} catch {
|
} catch {
|
||||||
break; // already removed via a sibling's climb
|
break; // already removed via a sibling's climb
|
||||||
}
|
}
|
||||||
if (left.some((f) => f !== "meta.ts")) break;
|
if (left.length) break;
|
||||||
if (left.length) await unlink(join(DOCS, dir, "meta.ts"));
|
|
||||||
await rmdir(join(DOCS, dir));
|
await rmdir(join(DOCS, dir));
|
||||||
report.push(["", dir, "removed (empty)"]);
|
report.push(["", dir, "removed (empty)"]);
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-23
@@ -2,42 +2,51 @@
|
|||||||
* Topic issues and their problem sub-issues: the ONE reading of "is this topic
|
* Topic issues and their problem sub-issues: the ONE reading of "is this topic
|
||||||
* finished", shared by close-topics.ts (closes the topic issue) and
|
* finished", shared by close-topics.ts (closes the topic issue) and
|
||||||
* release-topics.ts (publishes the topic's timever release) so the two can
|
* release-topics.ts (publishes the topic's timever release) so the two can
|
||||||
* never disagree about when a topic was done.
|
* never disagree about when a topic was done. Also the one reading of "is this
|
||||||
|
* Review issue finished", which close-topics.ts closes on.
|
||||||
*
|
*
|
||||||
* Rule: a topic is finished when it has a core set (`set:core` sub-issues) and
|
* Rule: a topic is finished when it has a core set (`set:core` sub-issues) and
|
||||||
* every one of them is closed. Optional and deferred problems are extra credit
|
* every one of them is closed. Optional and deferred problems are extra credit
|
||||||
* and never hold a topic open. A topic with no core sub-issues is never
|
* and never hold a topic open. A topic with no core sub-issues is never
|
||||||
* finished — that would be indistinguishable from a mis-labelled problem set.
|
* finished — that would be indistinguishable from a mis-labelled problem set.
|
||||||
*
|
*
|
||||||
|
* Review issues (`Review <N> — <Phase>`, label `review`) are day blocks for
|
||||||
|
* the transfer phase: problems were re-parented into them from their topics
|
||||||
|
* (a GitHub issue has one parent). A topic still OWNS such a problem —
|
||||||
|
* readProblems() returns it alongside the topic's direct sub-issues, with the
|
||||||
|
* ownership recovered by apps/api/src/reviews.ts (shared with the Worker's
|
||||||
|
* catalog reconcile) — so the finished rule and the release snapshots do not
|
||||||
|
* change when a problem moves. A Review issue is finished when every one of
|
||||||
|
* its sub-issues is closed: it is a fixed assignment, so the set labels do
|
||||||
|
* not matter there.
|
||||||
|
*
|
||||||
* Library: side-effect-free on import; every call takes the caller's github().
|
* Library: side-effect-free on import; every call takes the caller's github().
|
||||||
*/
|
*/
|
||||||
|
import type { Problem } from "../api/src/reviews.ts";
|
||||||
import type { GitHub } from "./github.ts";
|
import type { GitHub } from "./github.ts";
|
||||||
|
|
||||||
/** One problem sub-issue, narrowed from the sub_issues payload. */
|
/** Completion of a required set: a topic's core problems, or every problem of a review. */
|
||||||
export interface Problem {
|
|
||||||
number: number;
|
|
||||||
/** `LC <num> · <Name>`. */
|
|
||||||
title: string;
|
|
||||||
/** ISO instant the issue closed; null while it is open. */
|
|
||||||
closedAt: string | null;
|
|
||||||
labels: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Core {
|
export interface Core {
|
||||||
/** Core sub-issues, closed and open alike, lowest number first. */
|
/** The required problems, closed and open alike, in the order given. */
|
||||||
all: number[];
|
all: number[];
|
||||||
/** The core sub-issues still open — non-empty means the topic stays open. */
|
/** The required problems still open — non-empty means the issue stays open. */
|
||||||
pending: number[];
|
pending: number[];
|
||||||
/** When the last core sub-issue closed; null until the topic is finished. */
|
/** When the last required problem closed; null until the set is finished (never, if empty). */
|
||||||
finishedAt: string | null;
|
finishedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every sub-issue of a topic. The sub_issues payload carries the full issue
|
* Every problem a topic owns: its sub-issues plus the ones moved from it into
|
||||||
* objects (state + labels), so no per-problem follow-up request is needed.
|
* a Review issue (`moved`, from movedFromTopics()). The sub_issues payload
|
||||||
|
* carries the full issue objects (state + labels), so no per-problem
|
||||||
|
* follow-up request is needed.
|
||||||
*/
|
*/
|
||||||
export async function readProblems(gh: GitHub, topic: number): Promise<Problem[]> {
|
export async function readProblems(
|
||||||
const problems: Problem[] = [];
|
gh: GitHub,
|
||||||
|
topic: number,
|
||||||
|
moved: Map<number, Problem[]>,
|
||||||
|
): Promise<Problem[]> {
|
||||||
|
const problems = [...(moved.get(topic) ?? [])];
|
||||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
||||||
if (!raw || typeof raw !== "object") continue;
|
if (!raw || typeof raw !== "object") continue;
|
||||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||||
@@ -63,13 +72,18 @@ export function labelValue(problem: Problem, prefix: string): string | undefined
|
|||||||
return problem.labels.find((l) => l.startsWith(prefix))?.slice(prefix.length);
|
return problem.labels.find((l) => l.startsWith(prefix))?.slice(prefix.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A topic's rule: its `set:core` problems are the required set. */
|
||||||
export function coreSet(problems: Problem[]): Core {
|
export function coreSet(problems: Problem[]): Core {
|
||||||
const core = problems.filter((p) => labelValue(p, "set:") === "core");
|
return completion(problems.filter((p) => labelValue(p, "set:") === "core"));
|
||||||
const pending = core.filter((p) => !p.closedAt).map((p) => p.number);
|
}
|
||||||
|
|
||||||
|
/** The shared rule: finished when non-empty and every required problem is closed. */
|
||||||
|
export function completion(required: Problem[]): Core {
|
||||||
|
const pending = required.filter((p) => !p.closedAt).map((p) => p.number);
|
||||||
// ISO instants from the API share one format, so string order is time order.
|
// ISO instants from the API share one format, so string order is time order.
|
||||||
const finishedAt =
|
const finishedAt =
|
||||||
core.length && !pending.length
|
required.length && !pending.length
|
||||||
? core.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b))
|
? required.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b))
|
||||||
: null;
|
: null;
|
||||||
return { all: core.map((p) => p.number), pending, finishedAt };
|
return { all: required.map((p) => p.number), pending, finishedAt };
|
||||||
}
|
}
|
||||||
+3
-3
@@ -3,8 +3,8 @@
|
|||||||
* Find the NeetCode explanation video for every docs page that has none.
|
* Find the NeetCode explanation video for every docs page that has none.
|
||||||
*
|
*
|
||||||
* A finished solution page carries frontmatter `video: <YouTube id>` —
|
* A finished solution page carries frontmatter `video: <YouTube id>` —
|
||||||
* NeetCode's walkthrough of the problem — which the docs MDX `wrapper`
|
* NeetCode's walkthrough of the problem — which the site's MDX `wrapper`
|
||||||
* (`apps/docs/components/ProblemBadges.astro`) renders as the red
|
* (prdlk/cs `components/ProblemBadges.astro`) renders as the red
|
||||||
* "Explanation" badge beside the topic badges. `sync.ts` never writes that key
|
* "Explanation" badge beside the topic badges. `sync.ts` never writes that key
|
||||||
* (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without
|
* (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without
|
||||||
* one. This script lists every numbered page lacking `video:` and looks each
|
* one. This script lists every numbered page lacking `video:` and looks each
|
||||||
@@ -25,7 +25,7 @@ import { join } from "node:path";
|
|||||||
import { printTable } from "./report.ts";
|
import { printTable } from "./report.ts";
|
||||||
import { ROOT } from "./work.ts";
|
import { ROOT } from "./work.ts";
|
||||||
|
|
||||||
const DOCS = join(ROOT, "apps", "docs", "content");
|
const DOCS = join(ROOT, "docs");
|
||||||
const WRITE = process.argv.includes("--write");
|
const WRITE = process.argv.includes("--write");
|
||||||
|
|
||||||
/** Channel handles searched in order; the first exact match wins. */
|
/** Channel handles searched in order; the first exact match wins. */
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
.blume-verify/
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
import { defineConfig } from "blume";
|
|
||||||
import type { StandardSchema } from "blume/core/standard-schema.ts";
|
|
||||||
import { custom, filesystem } from "blume/sources";
|
|
||||||
import { githubReleasesSource } from "blume/sources/github-releases.ts";
|
|
||||||
import redirects from "./redirects";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GitHub releases → /changelog, dated by the topic's real finish instant.
|
|
||||||
*
|
|
||||||
* Blume dates a release entry by `published_at`, which the GitHub API cannot
|
|
||||||
* backdate — so the topic releases backfilled on one day (apps/cli/
|
|
||||||
* release-topics.ts) would all read that day. Their tags are timever
|
|
||||||
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the moment the topic finished, so this
|
|
||||||
* fetch rewrites each timever release's `published_at` to that instant before
|
|
||||||
* Blume maps it; anything else passes through untouched. The config-level
|
|
||||||
* `githubReleases()` accepts no `fetchImpl`, hence the `custom()` wrap of the
|
|
||||||
* underlying source (the `blume/sources/*.ts` path the Blume docs use for
|
|
||||||
* custom sources), with the context the built-in adapter would receive.
|
|
||||||
*/
|
|
||||||
const TIMEVER = /^(\d{4})(\d{2})(\d{2})\.(\d{2})(\d{2})(\d{2})\.\d{9}$/;
|
|
||||||
|
|
||||||
const releasesDatedByTag: typeof fetch = async (input, init) => {
|
|
||||||
const res = await fetch(input, init);
|
|
||||||
if (!res.ok) return res;
|
|
||||||
const releases: unknown = await res.json();
|
|
||||||
if (Array.isArray(releases)) {
|
|
||||||
for (const release of releases) {
|
|
||||||
const m =
|
|
||||||
typeof release?.tag_name === "string"
|
|
||||||
? release.tag_name.match(TIMEVER)
|
|
||||||
: null;
|
|
||||||
if (m)
|
|
||||||
release.published_at = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Blume reads only `ok` and the JSON body, so the original headers (whose
|
|
||||||
// content-length/encoding describe the upstream bytes) are not carried over.
|
|
||||||
return Response.json(releases, { status: res.status });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Blume's defaults (`inter`, `ibm-plex-mono`) are fetched from fonts.gstatic.com
|
|
||||||
// on every build, which has timed out on the Pages runner and, fully offline,
|
|
||||||
// silently ships no fonts at all. The same two families from Fontsource, as
|
|
||||||
// local files, keep the build network-free. Blume resolves `src` from this
|
|
||||||
// directory; the workspace links these packages under ./node_modules.
|
|
||||||
const inter = {
|
|
||||||
name: "Inter",
|
|
||||||
variants: [
|
|
||||||
{
|
|
||||||
src: "./node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2",
|
|
||||||
weight: "100..900",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const plexMono = {
|
|
||||||
name: "IBM Plex Mono",
|
|
||||||
variants: [
|
|
||||||
{
|
|
||||||
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2",
|
|
||||||
weight: 400,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
|
|
||||||
weight: 500,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom frontmatter, rendered as the badge row by
|
|
||||||
* components/ProblemBadges.astro. Problem pages: `topics:` is LeetCode's topic
|
|
||||||
* tags (written by apps/cli/sync.ts), `video:` the NeetCode walkthrough's
|
|
||||||
* YouTube id (apps/cli/videos.ts). Guide pages (content/guides/, created by
|
|
||||||
* sync): `list:` is the tag's LeetCode problem list. Blume rejects undeclared
|
|
||||||
* keys and takes any Standard Schema; these are hand-rolled so the docs
|
|
||||||
* workspace needs no zod. All optional — every other page carries none.
|
|
||||||
*/
|
|
||||||
const optional = <T>(
|
|
||||||
message: string,
|
|
||||||
valid: (value: unknown) => value is T,
|
|
||||||
): StandardSchema<unknown, T | undefined> => ({
|
|
||||||
"~standard": {
|
|
||||||
version: 1,
|
|
||||||
vendor: "prdlk",
|
|
||||||
validate: (value) =>
|
|
||||||
value === undefined || valid(value)
|
|
||||||
? { value }
|
|
||||||
: { issues: [{ message }] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const topicsSchema = optional(
|
|
||||||
"expected a list of { name, slug } LeetCode topic tags",
|
|
||||||
(value): value is { name: string; slug: string }[] =>
|
|
||||||
Array.isArray(value) &&
|
|
||||||
value.every(
|
|
||||||
(t) => typeof t?.name === "string" && typeof t?.slug === "string",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const videoSchema = optional(
|
|
||||||
"expected an 11-character YouTube video id",
|
|
||||||
(value): value is string =>
|
|
||||||
typeof value === "string" && /^[\w-]{11}$/.test(value),
|
|
||||||
);
|
|
||||||
const listSchema = optional(
|
|
||||||
"expected a https://leetcode.com/problem-list/<slug>/ URL",
|
|
||||||
(value): value is string =>
|
|
||||||
typeof value === "string" &&
|
|
||||||
/^https:\/\/leetcode\.com\/problem-list\/[\w-]+\/$/.test(value),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
title: "prdlk",
|
|
||||||
description: "Documentation for my LeetCode solutions",
|
|
||||||
logo: "./public/logo.svg",
|
|
||||||
deployment: {
|
|
||||||
site: "https://lc.prd.lk",
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
owner: "prdlk",
|
|
||||||
repo: "leetcode",
|
|
||||||
},
|
|
||||||
lastModified: "git",
|
|
||||||
redirects,
|
|
||||||
frontmatter: {
|
|
||||||
extend: { topics: topicsSchema, video: videoSchema, list: listSchema },
|
|
||||||
},
|
|
||||||
seo: {
|
|
||||||
og: { enabled: true },
|
|
||||||
rss: { enabled: true, types: ["changelog"] },
|
|
||||||
sitemap: true,
|
|
||||||
robots: true,
|
|
||||||
structuredData: true,
|
|
||||||
x: { handle: "@prdlk" },
|
|
||||||
},
|
|
||||||
// Blume's own assistant route is server-rendered (it reads the provider key
|
|
||||||
// per request), which a static Pages build cannot ship. The SRS Worker
|
|
||||||
// answers instead (apps/api/src/ask.ts: OpenRouter, grounded on this site's
|
|
||||||
// llms-full.txt, which `agents.llmsTxt` publishes).
|
|
||||||
ai: {
|
|
||||||
assistant: {
|
|
||||||
enabled: true,
|
|
||||||
endpoint: "https://srs-api.prdlk.workers.dev/ask",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
agents: {
|
|
||||||
llmsTxt: true,
|
|
||||||
mcp: { enabled: false },
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
sources: [
|
|
||||||
filesystem({ root: "content" }),
|
|
||||||
custom(
|
|
||||||
githubReleasesSource(
|
|
||||||
{
|
|
||||||
name: "changelog",
|
|
||||||
prefix: "changelog",
|
|
||||||
owner: "prdlk",
|
|
||||||
repo: "leetcode",
|
|
||||||
fetchImpl: releasesDatedByTag,
|
|
||||||
},
|
|
||||||
// What resolve.ts hands the built-in adapter: `.blume/cache/<name>/`,
|
|
||||||
// and a refresh on every load (the source reads nothing else).
|
|
||||||
{
|
|
||||||
projectRoot: import.meta.dirname,
|
|
||||||
cacheDir: `${import.meta.dirname}/.blume/cache/changelog`,
|
|
||||||
mode: "build",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
theme: {
|
|
||||||
fonts: { display: inter, body: inter, mono: plexMono },
|
|
||||||
},
|
|
||||||
navigation: {
|
|
||||||
sidebar: {
|
|
||||||
display: "flat", // "flat" | "group" | "page"
|
|
||||||
},
|
|
||||||
tabs: [
|
|
||||||
{ label: "Guides", path: "/guides" },
|
|
||||||
{ label: "Changelog", path: "/changelog" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "new-york",
|
|
||||||
"rsc": false,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "",
|
|
||||||
"css": "theme.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide",
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* Project MDX components, available on every page with no import, and layout
|
|
||||||
* slot overrides. Static `.astro` files ship no framework JS (a custom element
|
|
||||||
* carries any behaviour); interactive React lives in `islands/` instead.
|
|
||||||
*/
|
|
||||||
import { defineComponents } from "blume";
|
|
||||||
|
|
||||||
export default defineComponents({
|
|
||||||
mdx: {
|
|
||||||
Carousel: "./components/Carousel.astro",
|
|
||||||
CarouselImage: "./components/CarouselImage.astro",
|
|
||||||
// MDX's own content wrapper: problem pages' topic + explanation badges, from
|
|
||||||
// `topics:` / `video:` frontmatter, between the description and the body.
|
|
||||||
wrapper: "./components/ProblemBadges.astro",
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
// Topic landing + guide pages' "what links here": every problem pointing at the page.
|
|
||||||
PageFooter: "./components/BacklinksList.astro",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
|
|
||||||
* or guide page — every problem page that points at it, as `<num> <name>`
|
|
||||||
* rows in LeetCode-number order. Pages nothing points at (problem pages, the
|
|
||||||
* overview, the changelog) render nothing.
|
|
||||||
*
|
|
||||||
* A problem points at a page when either
|
|
||||||
* - one of its topic badges links there — the same `tagTarget()` answer
|
|
||||||
* `ProblemBadges.astro` renders, so this list and the badges cannot drift
|
|
||||||
* (a DFS problem is a backlink of the one DFS landing its badge picks); or
|
|
||||||
* - it lives under the page's route (`/hybrid-structures/lru-cache` under
|
|
||||||
* `/hybrid-structures`), so a topic no LeetCode tag names still lists its
|
|
||||||
* own problems.
|
|
||||||
*
|
|
||||||
* Problem pages are recognised by their `<num>. <name>` title, the shape
|
|
||||||
* `apps/cli/sync.ts` writes.
|
|
||||||
*
|
|
||||||
* The list is a native `<details>`, open on load: the chevron at the top
|
|
||||||
* right of the header row collapses it like an accordion, with no JS.
|
|
||||||
*/
|
|
||||||
import { getEntry } from "astro:content";
|
|
||||||
import data from "blume:data";
|
|
||||||
import { withBase } from "blume/components/islands/base-path.ts";
|
|
||||||
import Icon from "blume/components/Icon.astro";
|
|
||||||
import { tagTarget, type TopicTag } from "../topic-landings.ts";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
route: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Route {
|
|
||||||
path: string;
|
|
||||||
title: string;
|
|
||||||
collection: string;
|
|
||||||
entryId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { route } = Astro.props;
|
|
||||||
const routes: Route[] = data.routes;
|
|
||||||
const served = new Set(routes.map((r) => r.path));
|
|
||||||
|
|
||||||
const problems = routes.flatMap((r) => {
|
|
||||||
const m = r.title.match(/^(\d+)\.\s+(.+)$/);
|
|
||||||
return m ? [{ route: r, num: Number(m[1]), name: m[2]! }] : [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// A problem page is never a backlink target: skip the per-problem lookups there.
|
|
||||||
const backlinks = problems.some((p) => p.route.path === route)
|
|
||||||
? []
|
|
||||||
: (
|
|
||||||
await Promise.all(
|
|
||||||
problems.map(async (p) => {
|
|
||||||
if (p.route.path.startsWith(`${route}/`)) return p;
|
|
||||||
const entry = await getEntry(p.route.collection, p.route.entryId);
|
|
||||||
const tags: TopicTag[] = entry?.data.topics ?? [];
|
|
||||||
const page = { route: p.route.path, tags };
|
|
||||||
return tags.some((tag) => tagTarget(tag, page, served).href === route) ? p : undefined;
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.filter((p) => p !== undefined)
|
|
||||||
.sort((a, b) => a.num - b.num);
|
|
||||||
---
|
|
||||||
|
|
||||||
{
|
|
||||||
backlinks.length > 0 && (
|
|
||||||
<details
|
|
||||||
class="group not-prose mx-auto mt-12 max-w-content border-border border-t pt-6"
|
|
||||||
open
|
|
||||||
>
|
|
||||||
<summary class="mb-2 flex cursor-pointer list-none items-center justify-between gap-2 text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
|
|
||||||
<h2 class="flex items-baseline gap-2 font-medium text-xs uppercase tracking-wide" id="backlinks">
|
|
||||||
Backlinks
|
|
||||||
<span class="tabular-nums">{backlinks.length}</span>
|
|
||||||
</h2>
|
|
||||||
<Icon class="shrink-0 transition-transform group-open:rotate-180" name="chevron-down" size={16} />
|
|
||||||
</summary>
|
|
||||||
<ul class="text-sm">
|
|
||||||
{backlinks.map((p) => (
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
class="-mx-2 flex items-baseline gap-3 rounded-md px-2 py-1 text-foreground no-underline transition-colors hover:bg-muted"
|
|
||||||
href={withBase(p.route.path)}
|
|
||||||
>
|
|
||||||
<span class="w-10 shrink-0 text-right font-mono text-muted-foreground text-xs tabular-nums">{p.num}</span>
|
|
||||||
<span class="truncate">{p.name}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* A horizontal carousel of <CarouselImage> slides, built the way Blume's own
|
|
||||||
* content components are: static markup, a custom element for the behaviour,
|
|
||||||
* no framework. The track is a CSS scroll-snap strip (so it also swipes on
|
|
||||||
* touch and the browser does the animation); prev/next sit outside it and
|
|
||||||
* the play toggle sits below with a "n / N" counter.
|
|
||||||
*
|
|
||||||
* `time` is seconds per slide while playing (default 1.25); `autoplay` starts
|
|
||||||
* playing on load. Play wraps around, pauses whenever the tab is hidden, and
|
|
||||||
* never starts on its own for a reader who prefers reduced motion — the
|
|
||||||
* button still works for them. Manual navigation (buttons, arrow keys, swipe)
|
|
||||||
* does not stop playback; it only restarts the interval so a step is never
|
|
||||||
* doubled.
|
|
||||||
*/
|
|
||||||
import Icon from "blume/components/Icon.astro";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Seconds each slide stays while playing. */
|
|
||||||
time?: number;
|
|
||||||
autoplay?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { time = 1.25, autoplay = false } = Astro.props;
|
|
||||||
|
|
||||||
const buttonClass =
|
|
||||||
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:border-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40";
|
|
||||||
---
|
|
||||||
|
|
||||||
<blume-carousel
|
|
||||||
class="not-prose my-6 block"
|
|
||||||
data-time={String(time)}
|
|
||||||
data-autoplay={autoplay ? "" : undefined}
|
|
||||||
role="region"
|
|
||||||
aria-roledescription="carousel"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button class={buttonClass} data-prev type="button" aria-label="Previous slide">
|
|
||||||
<Icon class="rtl:-scale-x-100" name="chevron-left" size={18} />
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
class="flex min-w-0 flex-1 snap-x snap-mandatory overflow-x-auto scroll-smooth rounded-blume [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
||||||
data-track
|
|
||||||
tabindex="0"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
<button class={buttonClass} data-next type="button" aria-label="Next slide">
|
|
||||||
<Icon class="rtl:-scale-x-100" name="chevron-right" size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 flex items-center justify-center gap-3">
|
|
||||||
<button class={buttonClass} data-play type="button" aria-label="Play" aria-pressed="false">
|
|
||||||
<Icon class="[[aria-pressed=true]_&]:hidden" name="play" size={16} />
|
|
||||||
<Icon class="[[aria-pressed=false]_&]:hidden" name="pause" size={16} />
|
|
||||||
</button>
|
|
||||||
<span class="text-muted-foreground text-xs tabular-nums leading-5" data-count></span>
|
|
||||||
</div>
|
|
||||||
</blume-carousel>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
class BlumeCarousel extends HTMLElement {
|
|
||||||
#track!: HTMLElement;
|
|
||||||
#play!: HTMLButtonElement;
|
|
||||||
#count!: HTMLElement;
|
|
||||||
#timer: number | null = null;
|
|
||||||
#index = 0;
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.#track = this.querySelector("[data-track]")!;
|
|
||||||
this.#play = this.querySelector("[data-play]")!;
|
|
||||||
this.#count = this.querySelector("[data-count]")!;
|
|
||||||
const prev = this.querySelector<HTMLButtonElement>("[data-prev]")!;
|
|
||||||
const next = this.querySelector<HTMLButtonElement>("[data-next]")!;
|
|
||||||
|
|
||||||
const slides = this.#slides().length;
|
|
||||||
if (slides < 2) {
|
|
||||||
for (const b of [prev, next, this.#play]) b.disabled = true;
|
|
||||||
}
|
|
||||||
this.#render();
|
|
||||||
|
|
||||||
prev.addEventListener("click", () => this.#step(-1));
|
|
||||||
next.addEventListener("click", () => this.#step(1));
|
|
||||||
this.#play.addEventListener("click", () => (this.#timer === null ? this.#start() : this.#stop()));
|
|
||||||
this.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "ArrowLeft") this.#step(-1);
|
|
||||||
else if (event.key === "ArrowRight") this.#step(1);
|
|
||||||
else return;
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
// Swipes and trackpad scrolls move the track without #step; follow them
|
|
||||||
// once the scroll settles. Settle-only, not per event: a long smooth
|
|
||||||
// scroll from #step passes over intermediate slides and must not be
|
|
||||||
// read back as the reader's position mid-flight.
|
|
||||||
let settle: number | undefined;
|
|
||||||
this.#track.addEventListener(
|
|
||||||
"scroll",
|
|
||||||
() => {
|
|
||||||
window.clearTimeout(settle);
|
|
||||||
settle = window.setTimeout(() => this.#syncFromScroll(), 150);
|
|
||||||
},
|
|
||||||
{ passive: true }
|
|
||||||
);
|
|
||||||
document.addEventListener("visibilitychange", this.#onVisibility);
|
|
||||||
|
|
||||||
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
if (this.hasAttribute("data-autoplay") && slides > 1 && !reduced) this.#start();
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
this.#stop();
|
|
||||||
document.removeEventListener("visibilitychange", this.#onVisibility);
|
|
||||||
}
|
|
||||||
|
|
||||||
#slides(): HTMLElement[] {
|
|
||||||
return [...this.#track.querySelectorAll<HTMLElement>(":scope > blume-carousel-image")];
|
|
||||||
}
|
|
||||||
|
|
||||||
#step(delta: number) {
|
|
||||||
const slides = this.#slides();
|
|
||||||
this.#index = (this.#index + delta + slides.length) % slides.length;
|
|
||||||
slides[this.#index]!.scrollIntoView({ block: "nearest", inline: "start", behavior: "smooth" });
|
|
||||||
this.#render();
|
|
||||||
if (this.#timer !== null) this.#start(); // restart the interval from this slide
|
|
||||||
}
|
|
||||||
|
|
||||||
#syncFromScroll() {
|
|
||||||
const at = Math.round(this.#track.scrollLeft / this.#track.clientWidth);
|
|
||||||
if (at !== this.#index && Number.isFinite(at)) {
|
|
||||||
this.#index = Math.min(Math.max(at, 0), this.#slides().length - 1);
|
|
||||||
this.#render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#start() {
|
|
||||||
this.#stop();
|
|
||||||
const ms = Number(this.dataset.time) * 1000;
|
|
||||||
this.#timer = window.setInterval(() => this.#step(1), ms > 0 ? ms : 1250);
|
|
||||||
this.#play.setAttribute("aria-pressed", "true");
|
|
||||||
this.#play.setAttribute("aria-label", "Pause");
|
|
||||||
}
|
|
||||||
|
|
||||||
#stop() {
|
|
||||||
if (this.#timer !== null) window.clearInterval(this.#timer);
|
|
||||||
this.#timer = null;
|
|
||||||
this.#play.setAttribute("aria-pressed", "false");
|
|
||||||
this.#play.setAttribute("aria-label", "Play");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A background tab keeps its timers but not its layout; resume on return.
|
|
||||||
#wasPlaying = false;
|
|
||||||
#onVisibility = () => {
|
|
||||||
if (document.hidden) {
|
|
||||||
this.#wasPlaying = this.#timer !== null;
|
|
||||||
this.#stop();
|
|
||||||
} else if (this.#wasPlaying) this.#start();
|
|
||||||
};
|
|
||||||
|
|
||||||
#render() {
|
|
||||||
const slides = this.#slides();
|
|
||||||
this.#count.textContent = `${this.#index + 1} / ${slides.length}`;
|
|
||||||
slides.forEach((s, i) => s.setAttribute("aria-hidden", i === this.#index ? "false" : "true"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customElements.get("blume-carousel")) {
|
|
||||||
customElements.define("blume-carousel", BlumeCarousel);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* One slide of a <Carousel>: Blume's own <Frame> around an <img>, so a slide
|
|
||||||
* is pixel-identical to a framed image elsewhere on the site — `alt` doubles
|
|
||||||
* as the frame's hint line and `caption` is Markdown, exactly as in Frame.
|
|
||||||
*
|
|
||||||
* `src` accepts what MDX authors already write for images: a URL, a
|
|
||||||
* root-relative `/file.png` (served from public/, prefixed with the deploy
|
|
||||||
* base), or the page-relative `../../public/file.png` form. A relative path
|
|
||||||
* that does not pass through public/ has nothing to serve it, so it is a
|
|
||||||
* build error rather than a silent 404.
|
|
||||||
*/
|
|
||||||
import Frame from "blume/components/content/Frame.astro";
|
|
||||||
import { withBase } from "blume/components/islands/base-path.ts";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
src: string;
|
|
||||||
alt?: string;
|
|
||||||
caption?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { src, alt, caption } = Astro.props;
|
|
||||||
|
|
||||||
const resolveSrc = (value: string): string => {
|
|
||||||
if (URL.canParse(value)) return value;
|
|
||||||
if (value.startsWith("/")) return withBase(value);
|
|
||||||
const at = value.indexOf("public/");
|
|
||||||
if (at === -1) {
|
|
||||||
throw new Error(
|
|
||||||
`<CarouselImage src="${value}">: a relative src must point into public/ (or use /file.png)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return withBase(`/${value.slice(at + "public/".length)}`);
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
<blume-carousel-image
|
|
||||||
class="block w-full shrink-0 snap-center [&_blume-frame]:my-0"
|
|
||||||
role="group"
|
|
||||||
aria-roledescription="slide"
|
|
||||||
>
|
|
||||||
<Frame caption={caption} hint={alt}>
|
|
||||||
<img alt={alt ?? ""} src={resolveSrc(src)} loading="lazy" draggable="false" />
|
|
||||||
</Frame>
|
|
||||||
</blume-carousel-image>
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* The MDX `wrapper`: the badge row between a page's description and its body.
|
|
||||||
* Blume's page template renders the title and description itself and then
|
|
||||||
* the MDX content, with no slot in between, so wrapping the content is the
|
|
||||||
* only way to land right after the description. MDX hands a wrapper no
|
|
||||||
* route, so it is read off the URL, and the page's frontmatter is then
|
|
||||||
* looked up through the route manifest — the same `getEntry` call Blume's own
|
|
||||||
* page template makes. Pages with none of the keys below (landing pages, the
|
|
||||||
* changelog) render only their content.
|
|
||||||
*
|
|
||||||
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
|
|
||||||
* topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that
|
|
||||||
* topic's landing page and is filled; any other tag links to its guide
|
|
||||||
* page, `/guides/<slug>` (sync creates one per such tag), and is outlined.
|
|
||||||
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
|
|
||||||
* `PageFooter` backlinks list reads the same answer. Each tag badge is a
|
|
||||||
* pill carrying the icon its target shows in the sidebar (the topic's
|
|
||||||
* `meta.ts` icon, the guide's `icon:`) and the tag's own colour
|
|
||||||
* (`lib/tag-colors.ts`), assigned over every tag the site has a page for —
|
|
||||||
* no two tags share one, and tags that share problem pages are spread
|
|
||||||
* furthest apart. A tag with no page (the LeetCode-list fallback) stays
|
|
||||||
* gray and iconless.
|
|
||||||
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
|
|
||||||
* walkthrough's YouTube id, the red "Explanation" badge at the end of the row.
|
|
||||||
* - `list:` (guide pages, written by sync on create) is the tag's LeetCode
|
|
||||||
* problem list, an orange "LeetCode list" badge.
|
|
||||||
*/
|
|
||||||
import { getEntry } from "astro:content";
|
|
||||||
import data from "blume:data";
|
|
||||||
import Badge from "blume/components/content/Badge.astro";
|
|
||||||
import { stripBase, withBase } from "blume/components/islands/base-path.ts";
|
|
||||||
import { tagColors } from "../lib/tag-colors.ts";
|
|
||||||
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
|
|
||||||
|
|
||||||
// `/sub/tree-dfs/invert-binary-tree/` (or `.../index.html` in a static build)
|
|
||||||
// -> `/tree-dfs/invert-binary-tree`, the base-less shape of `data.routes`.
|
|
||||||
const route =
|
|
||||||
stripBase(import.meta.env.BASE_URL ?? "/", Astro.url.pathname)
|
|
||||||
.replace(/(\/index)?\.html$/u, "")
|
|
||||||
.replace(/(.)\/+$/u, "$1");
|
|
||||||
const at = data.routes.find((r: { path: string }) => r.path === route);
|
|
||||||
const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
|
|
||||||
const tags: TopicTag[] = entry?.data.topics ?? [];
|
|
||||||
const video: string | undefined = entry?.data.video;
|
|
||||||
const list: string | undefined = entry?.data.list;
|
|
||||||
|
|
||||||
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
|
|
||||||
|
|
||||||
// route → the icon its sidebar row shows: a page's own, or a topic group's
|
|
||||||
// (from its `meta.ts`) for the landing page the group row links to.
|
|
||||||
interface NavNode {
|
|
||||||
route?: string;
|
|
||||||
icon?: string;
|
|
||||||
children?: NavNode[];
|
|
||||||
}
|
|
||||||
const icons = new Map<string, string>();
|
|
||||||
const walk = (nodes: NavNode[]) => {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.route && node.icon && !icons.has(node.route)) icons.set(node.route, node.icon);
|
|
||||||
if (node.children) walk(node.children);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk(data.navigation.sidebar);
|
|
||||||
|
|
||||||
// Every tag with a page — the ones a landing teaches, plus every served guide —
|
|
||||||
// coloured against how problem pages (`<num>. <name>` titles, the shape sync
|
|
||||||
// writes) combine them. Only a page with tags needs the answer.
|
|
||||||
const colors = tags.length
|
|
||||||
? tagColors(
|
|
||||||
[
|
|
||||||
...Object.keys(TOPIC_LANDINGS),
|
|
||||||
...[...served].flatMap((path) => path.match(/^\/guides\/([^/]+)$/u)?.slice(1) ?? []),
|
|
||||||
],
|
|
||||||
await Promise.all(
|
|
||||||
data.routes
|
|
||||||
.filter((r: { title: string }) => /^\d+\.\s/u.test(r.title))
|
|
||||||
.map(async (r: { collection: string; entryId: string }) => {
|
|
||||||
const page = await getEntry(r.collection, r.entryId);
|
|
||||||
return (page?.data.topics ?? []).map((t: TopicTag) => t.slug);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: new Map<string, string>();
|
|
||||||
|
|
||||||
const badges = tags.map((tag) => {
|
|
||||||
const target = tagTarget(tag, { route, tags }, served);
|
|
||||||
const external = target.kind === "external";
|
|
||||||
return {
|
|
||||||
name: tag.name,
|
|
||||||
href: external ? target.href : withBase(target.href),
|
|
||||||
external,
|
|
||||||
icon: external ? undefined : icons.get(target.href),
|
|
||||||
color: external ? undefined : colors.get(tag.slug),
|
|
||||||
stroke: target.kind !== "landing",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const linkClass = "no-underline transition-opacity hover:opacity-80";
|
|
||||||
---
|
|
||||||
|
|
||||||
{
|
|
||||||
(badges.length > 0 || video || list) && (
|
|
||||||
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
|
|
||||||
{badges.map((b) => (
|
|
||||||
<a
|
|
||||||
href={b.href}
|
|
||||||
target={b.external ? "_blank" : undefined}
|
|
||||||
rel={b.external ? "noopener" : undefined}
|
|
||||||
class={linkClass}
|
|
||||||
>
|
|
||||||
<Badge color={b.color} icon={b.icon} shape="pill" stroke={b.stroke}>
|
|
||||||
{b.name}
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
{video && (
|
|
||||||
<a href={`https://www.youtube.com/watch?v=${video}`} target="_blank" rel="noopener" class={linkClass}>
|
|
||||||
<Badge variant="danger" icon="youtube" shape="pill">
|
|
||||||
Explanation
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{list && (
|
|
||||||
<a href={list} target="_blank" rel="noopener" class={linkClass}>
|
|
||||||
<Badge variant="warning" icon="list" shape="pill">
|
|
||||||
LeetCode list
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
<slot />
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* What the four progress islands share besides the data: the not-ready body
|
|
||||||
* (a skeleton while /api/stats loads, one muted line if it fails) and the
|
|
||||||
* hover tooltip for the two charts drawn as plain SVG (the heatmap calendar
|
|
||||||
* and the topic bubbles — Recharts has neither layout). The tooltip wears the
|
|
||||||
* exact classes of shadcn's ChartTooltipContent, so all four cards' tooltips
|
|
||||||
* look like one component.
|
|
||||||
*/
|
|
||||||
import { type PointerEvent, type ReactNode, useRef, useState } from "react";
|
|
||||||
import { cn } from "../../lib/utils";
|
|
||||||
import type { Load } from "../../lib/stats";
|
|
||||||
import { Skeleton } from "../ui/skeleton";
|
|
||||||
|
|
||||||
export function StatsPending({ load, className }: { load: Exclude<Load, { phase: "ready" }>; className: string }) {
|
|
||||||
if (load.phase === "loading") return <Skeleton className={className} />;
|
|
||||||
return (
|
|
||||||
<p className="my-0! text-muted-foreground text-sm">
|
|
||||||
Stats unavailable — they are served live from the SRS Worker, and it did not respond.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Tip {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
content: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pointer-following tooltip for SVG marks. Put `ref` on a `relative`
|
|
||||||
* wrapper, call `show` from a mark's pointer events, render `tip` inside the
|
|
||||||
* wrapper. Positions are relative to the wrapper, so page scroll never skews it.
|
|
||||||
*/
|
|
||||||
export function useHoverTip<T extends HTMLElement>() {
|
|
||||||
const ref = useRef<T>(null);
|
|
||||||
const [tip, setTip] = useState<Tip | null>(null);
|
|
||||||
const show = (event: PointerEvent, content: ReactNode) => {
|
|
||||||
const box = ref.current?.getBoundingClientRect();
|
|
||||||
if (box) setTip({ x: event.clientX - box.left, y: event.clientY - box.top, content });
|
|
||||||
};
|
|
||||||
const hide = () => setTip(null);
|
|
||||||
const node = tip && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute z-10 grid min-w-[8rem] -translate-x-1/2 -translate-y-[calc(100%+0.75rem)] items-start gap-1.5",
|
|
||||||
"rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
|
||||||
)}
|
|
||||||
style={{ left: tip.x, top: tip.y }}
|
|
||||||
>
|
|
||||||
{tip.content}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return { ref, show, hide, node };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One tooltip row, the shape ChartTooltipContent draws: colour dot, label, mono value. */
|
|
||||||
export function TipRow({ color, label, value }: { color: string; label: ReactNode; value: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex w-full items-center gap-2">
|
|
||||||
<div className="size-2.5 shrink-0 rounded-[2px]" style={{ background: color }} />
|
|
||||||
<div className="flex flex-1 items-center justify-between gap-4 leading-none">
|
|
||||||
<span className="text-muted-foreground">{label}</span>
|
|
||||||
<span className="font-medium font-mono text-foreground tabular-nums">{value}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "../../lib/utils"
|
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-header"
|
|
||||||
className={cn(
|
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-title"
|
|
||||||
className={cn("leading-none font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-description"
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-action"
|
|
||||||
className={cn(
|
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-content"
|
|
||||||
className={cn("px-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-footer"
|
|
||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "../../lib/utils"
|
|
||||||
import * as RechartsPrimitive from "recharts"
|
|
||||||
import type { TooltipValueType } from "recharts"
|
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }. Blume switches dark mode with
|
|
||||||
// [data-theme="dark"] on <html>, not shadcn's .dark class.
|
|
||||||
const THEMES = { light: "", dark: ':root[data-theme="dark"]' } as const
|
|
||||||
|
|
||||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
|
||||||
type TooltipNameType = number | string
|
|
||||||
|
|
||||||
export type ChartConfig = Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
label?: React.ReactNode
|
|
||||||
icon?: React.ComponentType
|
|
||||||
} & (
|
|
||||||
| { color?: string; theme?: never }
|
|
||||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
|
||||||
)
|
|
||||||
>
|
|
||||||
|
|
||||||
type ChartContextProps = {
|
|
||||||
config: ChartConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
|
||||||
|
|
||||||
function useChart() {
|
|
||||||
const context = React.useContext(ChartContext)
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useChart must be used within a <ChartContainer />")
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChartContainer({
|
|
||||||
id,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
config,
|
|
||||||
initialDimension = INITIAL_DIMENSION,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
config: ChartConfig
|
|
||||||
children: React.ComponentProps<
|
|
||||||
typeof RechartsPrimitive.ResponsiveContainer
|
|
||||||
>["children"]
|
|
||||||
initialDimension?: {
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
const uniqueId = React.useId()
|
|
||||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChartContext.Provider value={{ config }}>
|
|
||||||
<div
|
|
||||||
data-slot="chart"
|
|
||||||
data-chart={chartId}
|
|
||||||
className={cn(
|
|
||||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChartStyle id={chartId} config={config} />
|
|
||||||
<RechartsPrimitive.ResponsiveContainer
|
|
||||||
initialDimension={initialDimension}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</RechartsPrimitive.ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</ChartContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
|
||||||
const colorConfig = Object.entries(config).filter(
|
|
||||||
([, config]) => config.theme ?? config.color
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!colorConfig.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<style
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: Object.entries(THEMES)
|
|
||||||
.map(
|
|
||||||
([theme, prefix]) => `
|
|
||||||
${prefix} [data-chart=${id}] {
|
|
||||||
${colorConfig
|
|
||||||
.map(([key, itemConfig]) => {
|
|
||||||
const color =
|
|
||||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
|
||||||
itemConfig.color
|
|
||||||
return color ? ` --color-${key}: ${color};` : null
|
|
||||||
})
|
|
||||||
.join("\n")}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.join("\n"),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
|
||||||
|
|
||||||
function ChartTooltipContent({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
className,
|
|
||||||
indicator = "dot",
|
|
||||||
hideLabel = false,
|
|
||||||
hideIndicator = false,
|
|
||||||
label,
|
|
||||||
labelFormatter,
|
|
||||||
labelClassName,
|
|
||||||
formatter,
|
|
||||||
color,
|
|
||||||
nameKey,
|
|
||||||
labelKey,
|
|
||||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
|
||||||
React.ComponentProps<"div"> & {
|
|
||||||
hideLabel?: boolean
|
|
||||||
hideIndicator?: boolean
|
|
||||||
indicator?: "line" | "dot" | "dashed"
|
|
||||||
nameKey?: string
|
|
||||||
labelKey?: string
|
|
||||||
} & Omit<
|
|
||||||
RechartsPrimitive.DefaultTooltipContentProps<
|
|
||||||
TooltipValueType,
|
|
||||||
TooltipNameType
|
|
||||||
>,
|
|
||||||
"accessibilityLayer"
|
|
||||||
>) {
|
|
||||||
const { config } = useChart()
|
|
||||||
|
|
||||||
const tooltipLabel = React.useMemo(() => {
|
|
||||||
if (hideLabel || !payload?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const [item] = payload
|
|
||||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
|
||||||
const value =
|
|
||||||
!labelKey && typeof label === "string"
|
|
||||||
? (config[label]?.label ?? label)
|
|
||||||
: itemConfig?.label
|
|
||||||
|
|
||||||
if (labelFormatter) {
|
|
||||||
return (
|
|
||||||
<div className={cn("font-medium", labelClassName)}>
|
|
||||||
{labelFormatter(value, payload)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
|
||||||
}, [
|
|
||||||
label,
|
|
||||||
labelFormatter,
|
|
||||||
payload,
|
|
||||||
hideLabel,
|
|
||||||
labelClassName,
|
|
||||||
config,
|
|
||||||
labelKey,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!active || !payload?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!nestLabel ? tooltipLabel : null}
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
{payload
|
|
||||||
.filter((item) => item.type !== "none")
|
|
||||||
.map((item, index) => {
|
|
||||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
|
||||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
|
||||||
indicator === "dot" && "items-center"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatter && item?.value !== undefined && item.name ? (
|
|
||||||
formatter(item.value, item.name, item, index, item.payload)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{itemConfig?.icon ? (
|
|
||||||
<itemConfig.icon />
|
|
||||||
) : (
|
|
||||||
!hideIndicator && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
|
||||||
{
|
|
||||||
"h-2.5 w-2.5": indicator === "dot",
|
|
||||||
"w-1": indicator === "line",
|
|
||||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
|
||||||
indicator === "dashed",
|
|
||||||
"my-0.5": nestLabel && indicator === "dashed",
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--color-bg": indicatorColor,
|
|
||||||
"--color-border": indicatorColor,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-1 justify-between leading-none",
|
|
||||||
nestLabel ? "items-end" : "items-center"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
{nestLabel ? tooltipLabel : null}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{itemConfig?.label ?? item.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{item.value != null && (
|
|
||||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
|
||||||
{typeof item.value === "number"
|
|
||||||
? item.value.toLocaleString()
|
|
||||||
: String(item.value)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChartLegend = RechartsPrimitive.Legend
|
|
||||||
|
|
||||||
function ChartLegendContent({
|
|
||||||
className,
|
|
||||||
hideIcon = false,
|
|
||||||
payload,
|
|
||||||
verticalAlign = "bottom",
|
|
||||||
nameKey,
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
hideIcon?: boolean
|
|
||||||
nameKey?: string
|
|
||||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
|
||||||
const { config } = useChart()
|
|
||||||
|
|
||||||
if (!payload?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center gap-4",
|
|
||||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{payload
|
|
||||||
.filter((item) => item.type !== "none")
|
|
||||||
.map((item, index) => {
|
|
||||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{itemConfig?.icon && !hideIcon ? (
|
|
||||||
<itemConfig.icon />
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
|
||||||
style={{
|
|
||||||
backgroundColor: item.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{itemConfig?.label}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to extract item config from a payload.
|
|
||||||
function getPayloadConfigFromPayload(
|
|
||||||
config: ChartConfig,
|
|
||||||
payload: unknown,
|
|
||||||
key: string
|
|
||||||
) {
|
|
||||||
if (typeof payload !== "object" || payload === null) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloadPayload =
|
|
||||||
"payload" in payload &&
|
|
||||||
typeof payload.payload === "object" &&
|
|
||||||
payload.payload !== null
|
|
||||||
? payload.payload
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
let configLabelKey: string = key
|
|
||||||
|
|
||||||
if (
|
|
||||||
key in payload &&
|
|
||||||
typeof payload[key as keyof typeof payload] === "string"
|
|
||||||
) {
|
|
||||||
configLabelKey = payload[key as keyof typeof payload] as string
|
|
||||||
} else if (
|
|
||||||
payloadPayload &&
|
|
||||||
key in payloadPayload &&
|
|
||||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
|
||||||
) {
|
|
||||||
configLabelKey = payloadPayload[
|
|
||||||
key as keyof typeof payloadPayload
|
|
||||||
] as string
|
|
||||||
}
|
|
||||||
|
|
||||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
ChartLegend,
|
|
||||||
ChartLegendContent,
|
|
||||||
ChartStyle,
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { cn } from "../../lib/utils"
|
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="skeleton"
|
|
||||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Skeleton }
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { type VariantProps } from "class-variance-authority"
|
|
||||||
import { cn } from "../../lib/utils"
|
|
||||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { toggleVariants } from "./toggle"
|
|
||||||
|
|
||||||
const ToggleGroupContext = React.createContext<
|
|
||||||
VariantProps<typeof toggleVariants> & {
|
|
||||||
spacing?: number
|
|
||||||
}
|
|
||||||
>({
|
|
||||||
size: "default",
|
|
||||||
variant: "default",
|
|
||||||
spacing: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
function ToggleGroup({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
spacing = 0,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
|
||||||
VariantProps<typeof toggleVariants> & {
|
|
||||||
spacing?: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ToggleGroupPrimitive.Root
|
|
||||||
data-slot="toggle-group"
|
|
||||||
data-variant={variant}
|
|
||||||
data-size={size}
|
|
||||||
data-spacing={spacing}
|
|
||||||
style={{ "--gap": spacing } as React.CSSProperties}
|
|
||||||
className={cn(
|
|
||||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
|
||||||
{children}
|
|
||||||
</ToggleGroupContext.Provider>
|
|
||||||
</ToggleGroupPrimitive.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToggleGroupItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
|
||||||
VariantProps<typeof toggleVariants>) {
|
|
||||||
const context = React.useContext(ToggleGroupContext)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToggleGroupPrimitive.Item
|
|
||||||
data-slot="toggle-group-item"
|
|
||||||
data-variant={context.variant || variant}
|
|
||||||
data-size={context.size || size}
|
|
||||||
data-spacing={context.spacing}
|
|
||||||
className={cn(
|
|
||||||
toggleVariants({
|
|
||||||
variant: context.variant || variant,
|
|
||||||
size: context.size || size,
|
|
||||||
}),
|
|
||||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
|
||||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ToggleGroupPrimitive.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ToggleGroup, ToggleGroupItem }
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { cn } from "../../lib/utils"
|
|
||||||
import { Toggle as TogglePrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
// Blume's `accent` is its brand colour, not shadcn's subtle surface: the on
|
|
||||||
// and hover states use background/muted instead (see theme.css).
|
|
||||||
|
|
||||||
const toggleVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-transparent",
|
|
||||||
outline:
|
|
||||||
"border border-input bg-transparent shadow-xs hover:bg-muted hover:text-foreground",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-9 min-w-9 px-2",
|
|
||||||
sm: "h-8 min-w-8 px-1.5",
|
|
||||||
lg: "h-10 min-w-10 px-2.5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Toggle({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
|
||||||
VariantProps<typeof toggleVariants>) {
|
|
||||||
return (
|
|
||||||
<TogglePrimitive.Root
|
|
||||||
data-slot="toggle"
|
|
||||||
className={cn(toggleVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Toggle, toggleVariants }
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
title: Binary Search
|
|
||||||
description: 'Half of a sorted range is removed at each step until the target is found.'
|
|
||||||
hidden: true
|
|
||||||
---
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({
|
|
||||||
title: "Binary Search",
|
|
||||||
order: 5,
|
|
||||||
icon: "search",
|
|
||||||
display: "group",
|
|
||||||
});
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
title: Hash Table
|
|
||||||
description: 'A key maps to a value in constant time, which removes the need for a full scan.'
|
|
||||||
hidden: true
|
|
||||||
---
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({
|
|
||||||
title: "Hash Table",
|
|
||||||
order: 1,
|
|
||||||
icon: "hash",
|
|
||||||
display: "group",
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "1. Linear Structures", order: 1 });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Monotonic Stack", order: 7, icon: "trending-up", display: "group" });
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
title: Prefix Sum
|
|
||||||
description: 'A running total is stored at each index, so the sum of any range is one subtraction.'
|
|
||||||
hidden: true
|
|
||||||
---
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Prefix Sum", order: 2, icon: "sigma", display: "group" });
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
---
|
|
||||||
title: Sliding Window
|
|
||||||
description: 'Sliding window is a specialized two-pointer family where the pointers define a contiguous interval and you maintain information about that entire interval as it moves.'
|
|
||||||
hidden: true
|
|
||||||
---
|
|
||||||
|
|
||||||
:::warning
|
|
||||||
This is the family where invariants matter the most
|
|
||||||
:::
|
|
||||||
|
|
||||||
## What Makes Sliding Window Different?
|
|
||||||
|
|
||||||
Many problems have two moving boundaries. That alone does not make them sliding window problems.
|
|
||||||
|
|
||||||
A problem belongs to this family when:
|
|
||||||
|
|
||||||
- the answer depends on the subarray or substring between left and right
|
|
||||||
- you can update the window state incrementally as elements enter or leave
|
|
||||||
- you maintain a condition that tells you whether the window is valid
|
|
||||||
|
|
||||||
## What is an Invariant?
|
|
||||||
|
|
||||||
|
|
||||||
An invariant is a statement that stays true before and after every iteration.
|
|
||||||
|
|
||||||
For sliding window, the invariant often looks like one of these:
|
|
||||||
|
|
||||||
- "the current window is valid"
|
|
||||||
- "the current window contains no duplicates"
|
|
||||||
- "the current window sum is at most the target"
|
|
||||||
- "the frequency map matches the characters currently inside the window"
|
|
||||||
|
|
||||||
The invariant gives the loop its structure:
|
|
||||||
|
|
||||||
- expand the window by moving right
|
|
||||||
- update the window state
|
|
||||||
- if the invariant breaks, shrink from the left until it holds again
|
|
||||||
- update the answer only when you know the invariant is satisfied
|
|
||||||
|
|
||||||
|
|
||||||
## Why Invariants Matter
|
|
||||||
|
|
||||||
Without an invariant, shrinking the window becomes arbitrary. You no longer know:
|
|
||||||
|
|
||||||
- when the window is valid
|
|
||||||
- when to update the answer
|
|
||||||
- why the left pointer should stop moving
|
|
||||||
- The invariant is the reason the algorithm is correct.
|
|
||||||
|
|
||||||
## The Main Subtypes
|
|
||||||
|
|
||||||
### 1. Fixed-size windows
|
|
||||||
|
|
||||||
:::tip
|
|
||||||
The window size never changes. The main task is updating the window state efficiently when one element enters and one leaves.
|
|
||||||
:::
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 2. Flexible-size windows with a validity rule
|
|
||||||
|
|
||||||
:::tip
|
|
||||||
Here the window expands until invalid, then shrinks until valid again.
|
|
||||||
:::
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
### 3. Frequency-tracking windows
|
|
||||||
|
|
||||||
:::tip
|
|
||||||
The difficulty comes from keeping the bookkeeping correct as characters enter and leave.
|
|
||||||
:::
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## How Sliding Window Differs From Other Two-Pointer Families
|
|
||||||
|
|
||||||
Compare it with Container With Most Water:
|
|
||||||
|
|
||||||
- In sliding window, the interval itself has meaning.
|
|
||||||
- In container, the interval is just the remaining search space.
|
|
||||||
|
|
||||||
Compare it with Remove Duplicates:
|
|
||||||
|
|
||||||
- In sliding window, both pointers describe a live window.
|
|
||||||
- In compaction, the left part of the array is already the answer prefix.
|
|
||||||
|
|
||||||
## A Practical Template
|
|
||||||
|
|
||||||
A common variable-size sliding window has this shape:
|
|
||||||
|
|
||||||
1. Add the new rightmost element.
|
|
||||||
2. Update the window state.
|
|
||||||
3. While the invariant is broken, remove elements from the left.
|
|
||||||
4. Once the invariant is restored, update the answer.
|
|
||||||
|
|
||||||
The exact data structure changes from problem to problem, but the logic stays the same.
|
|
||||||
|
|
||||||
As you work through this section, keep asking:
|
|
||||||
|
|
||||||
:::warning
|
|
||||||
What invariant does my window maintain, and at what moment is it safe to update the answer?
|
|
||||||
:::
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Sliding Window", order: 4, icon: "app-window", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Stack", order: 6, icon: "layers", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Two Pointers", order: 3, icon: "arrow-left-right", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Hybrid Structures", order: 9, icon: "combine", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Linked List", order: 8, icon: "link", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Matrix Index Math", order: 10, icon: "grid-3x3", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "2. Nodal & Grid", order: 2 });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Binary Search Tree", order: 13, icon: "git-fork", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Heap / Priority Queue", order: 14, icon: "triangle", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "3. Hierarchical", order: 3 });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Tree BFS", order: 12, icon: "network", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Tree DFS", order: 11, icon: "folder-tree", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Trie", order: 15, icon: "spell-check", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Graph BFS", order: 17, icon: "radar", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Graph DFS", order: 16, icon: "waypoints", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "4. Relational", order: 4 });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Topological Sort", order: 18, icon: "workflow", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Union-Find", order: 19, icon: "merge", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "1-D Dynamic Programming", order: 21, icon: "columns-3", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Backtracking", order: 20, icon: "undo-2", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Greedy", order: 23, icon: "coins", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Intervals", order: 24, icon: "calendar-range", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "5. Decision Space", order: 5 });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Multi-D / Grid DP", order: 22, icon: "table", display: "group" });
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Array"
|
|
||||||
list: "https://leetcode.com/problem-list/array/"
|
|
||||||
icon: list-ordered
|
|
||||||
sidebar:
|
|
||||||
badge: '40'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Array.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Binary Tree"
|
|
||||||
list: "https://leetcode.com/problem-list/binary-tree/"
|
|
||||||
icon: git-branch
|
|
||||||
sidebar:
|
|
||||||
badge: '9'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Binary Tree.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Directed Acyclic Graph"
|
|
||||||
list: "https://leetcode.com/problem-list/directed-acyclic-graph/"
|
|
||||||
icon: route
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Directed Acyclic Graph.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Doubly-Linked List"
|
|
||||||
list: "https://leetcode.com/problem-list/doubly-linked-list/"
|
|
||||||
icon: link-2
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Doubly-Linked List.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Graph Theory"
|
|
||||||
list: "https://leetcode.com/problem-list/graph/"
|
|
||||||
icon: share-2
|
|
||||||
sidebar:
|
|
||||||
badge: '5'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Graph Theory.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "K-D Tree"
|
|
||||||
list: "https://leetcode.com/problem-list/k-d-tree/"
|
|
||||||
icon: axis-3d
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged K-D Tree.
|
|
||||||
:::
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Data Structures", order: 1 });
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Queue"
|
|
||||||
list: "https://leetcode.com/problem-list/queue/"
|
|
||||||
icon: list-end
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Queue.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "String"
|
|
||||||
list: "https://leetcode.com/problem-list/string/"
|
|
||||||
icon: quote
|
|
||||||
sidebar:
|
|
||||||
badge: '16'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged String.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Tree"
|
|
||||||
list: "https://leetcode.com/problem-list/tree/"
|
|
||||||
icon: tree-deciduous
|
|
||||||
sidebar:
|
|
||||||
badge: '9'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Tree.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Bidirectional Search"
|
|
||||||
list: "https://leetcode.com/problem-list/bidirectional-search/"
|
|
||||||
icon: move-horizontal
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Bidirectional Search.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Binary Lifting"
|
|
||||||
list: "https://leetcode.com/problem-list/binary-lifting/"
|
|
||||||
icon: chevrons-up
|
|
||||||
sidebar:
|
|
||||||
badge: '2'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Binary Lifting.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Bit Manipulation"
|
|
||||||
list: "https://leetcode.com/problem-list/bit-manipulation/"
|
|
||||||
icon: binary
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Bit Manipulation.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Bracket Sequences"
|
|
||||||
list: "https://leetcode.com/problem-list/bracket-sequences/"
|
|
||||||
icon: parentheses
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Bracket Sequences.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Bucket Sort"
|
|
||||||
list: "https://leetcode.com/problem-list/bucket-sort/"
|
|
||||||
icon: container
|
|
||||||
sidebar:
|
|
||||||
badge: '2'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Bucket Sort.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Complete Knapsack"
|
|
||||||
list: "https://leetcode.com/problem-list/complete-knapsack/"
|
|
||||||
icon: package-plus
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Complete Knapsack.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Divide and Conquer"
|
|
||||||
list: "https://leetcode.com/problem-list/divide-and-conquer/"
|
|
||||||
icon: split
|
|
||||||
sidebar:
|
|
||||||
badge: '5'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Divide and Conquer.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Floyd's Cycle Finding Algorithm"
|
|
||||||
list: "https://leetcode.com/problem-list/floyds-cycle-finding-algorithm/"
|
|
||||||
icon: repeat
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Floyd's Cycle Finding Algorithm.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Knapsack Problem"
|
|
||||||
list: "https://leetcode.com/problem-list/knapsack-problem/"
|
|
||||||
icon: backpack
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Knapsack Problem.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Longest Common Subsequence"
|
|
||||||
list: "https://leetcode.com/problem-list/longest-common-subsequence/"
|
|
||||||
icon: diff
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Longest Common Subsequence.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Lowest Common Ancestor"
|
|
||||||
list: "https://leetcode.com/problem-list/lowest-common-ancestor/"
|
|
||||||
icon: git-merge
|
|
||||||
sidebar:
|
|
||||||
badge: '2'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Lowest Common Ancestor.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Memoization"
|
|
||||||
list: "https://leetcode.com/problem-list/memoization/"
|
|
||||||
icon: database
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Memoization.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Merge Sort"
|
|
||||||
list: "https://leetcode.com/problem-list/merge-sort/"
|
|
||||||
icon: blend
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Merge Sort.
|
|
||||||
:::
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Algorithms", order: 2 });
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Quickselect"
|
|
||||||
list: "https://leetcode.com/problem-list/quickselect/"
|
|
||||||
icon: crosshair
|
|
||||||
sidebar:
|
|
||||||
badge: '3'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Quickselect.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Recursion"
|
|
||||||
list: "https://leetcode.com/problem-list/recursion/"
|
|
||||||
icon: iteration-ccw
|
|
||||||
sidebar:
|
|
||||||
badge: '3'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Recursion.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Simulation"
|
|
||||||
list: "https://leetcode.com/problem-list/simulation/"
|
|
||||||
icon: monitor-play
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Simulation.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Sorting"
|
|
||||||
list: "https://leetcode.com/problem-list/sorting/"
|
|
||||||
icon: arrow-down-wide-narrow
|
|
||||||
sidebar:
|
|
||||||
badge: '11'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Sorting.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Tournament Sort"
|
|
||||||
list: "https://leetcode.com/problem-list/tournament-sort/"
|
|
||||||
icon: trophy
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Tournament Sort.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Combinatorics"
|
|
||||||
list: "https://leetcode.com/problem-list/combinatorics/"
|
|
||||||
icon: dices
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Combinatorics.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Counting"
|
|
||||||
list: "https://leetcode.com/problem-list/counting/"
|
|
||||||
icon: tally-5
|
|
||||||
sidebar:
|
|
||||||
badge: '3'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Counting.
|
|
||||||
:::
|
|
||||||
Loaded 100 of 278 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user