mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
Compare commits
126
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 | ||
|
|
e1c8f1a0f1 | ||
|
|
57a5917e09 | ||
|
|
f8e90d7f55 | ||
|
|
a39336f7ed | ||
|
|
6396f1fbec | ||
|
|
82fe05b225 | ||
|
|
d7fbe88eb8 | ||
|
|
856ab0667a | ||
|
|
2c574018fb | ||
|
|
1f6413f3d8 | ||
|
|
3b9079f87f | ||
|
|
d5496581df | ||
|
|
e931391200 | ||
|
|
790e256127 | ||
|
|
a8bd4fbcd7 | ||
|
|
a7737502e1 | ||
|
|
2ac54e9930 | ||
|
|
ef91c0d1fe | ||
|
|
910e83fa64 | ||
|
|
3c9feb33fe | ||
|
|
b76b199b17 | ||
|
|
cd9aef1229 | ||
|
|
34b944d32d | ||
|
|
b710c777c3 | ||
|
|
19dedc52ce | ||
|
|
d9dc90e433 | ||
|
|
9c2aaf5969 | ||
|
|
a30e593202 | ||
|
|
d194cedf7a | ||
|
|
5d2d1dff3c | ||
|
|
dde06daea3 | ||
|
|
6e9c407698 | ||
|
|
63f0a81af3 | ||
|
|
5ed33057ed | ||
|
|
f4fc0fd0e7 | ||
|
|
f2a2d2d7de | ||
|
|
fa74adeda7 | ||
|
|
e11c9723f8 | ||
|
|
d2c645bef8 | ||
|
|
2f94b02e15 | ||
|
|
6f1cb93395 | ||
|
|
b310f410a2 | ||
|
|
436ff3080e | ||
|
|
d3f7b1c6f9 | ||
|
|
8a331f4b54 | ||
|
|
bc6eb3e66b | ||
|
|
dd929cca6c | ||
|
|
5987fdce7d | ||
|
|
6879afa61c | ||
|
|
40e965aa06 | ||
|
|
eea8d242e0 | ||
|
|
50b42108eb | ||
|
|
6f375e6c14 | ||
|
|
667bc06965 | ||
|
|
e63d517c9a | ||
|
|
4c297b4524 | ||
|
|
32dadf5928 |
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
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Pre-commit: the docs pages under apps/docs/content (and the `/<num>` short
|
|
||||||
# links in apps/docs/redirects.ts) are generated from work/1 by `bun run sync`,
|
|
||||||
# and deploy.yml publishes whatever main holds. Regenerate them now and stage
|
|
||||||
# what sync changed, so a solution, its page and its redirect land in the same
|
|
||||||
# commit and a stale `## Solution` never reaches main.
|
|
||||||
#
|
|
||||||
# Sync reads the working tree, not the index, so the pages it writes only
|
|
||||||
# match this commit when work/1 has nothing unstaged: an untracked or
|
|
||||||
# half-staged solution would ship a page whose source is not in the commit.
|
|
||||||
# In that case refuse and say what to stage.
|
|
||||||
#
|
|
||||||
# Only paths that became dirty during sync are staged. Anything already dirty
|
|
||||||
# beforehand (hand-written `## Approach` prose in progress, a new index.mdx)
|
|
||||||
# stays where the author left it.
|
|
||||||
#
|
|
||||||
# Bypass for a deliberate partial commit: git commit --no-verify
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
cd "$(git rev-parse --show-toplevel)"
|
|
||||||
|
|
||||||
unstaged=$(git status --porcelain -- work/1 | grep -v '^[MADR] ' || true)
|
|
||||||
if [ -n "$unstaged" ]; then
|
|
||||||
printf 'pre-commit: work/1 has unstaged changes; stage or stash them so the generated pages match this commit:\n%s\n' "$unstaged" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
before=$(mktemp)
|
|
||||||
trap 'rm -f "$before"' EXIT
|
|
||||||
git status --porcelain -- apps/docs/content apps/docs/redirects.ts | sort > "$before"
|
|
||||||
report=$(bun run --silent sync)
|
|
||||||
generated=$(git status --porcelain -- apps/docs/content apps/docs/redirects.ts | sort | comm -13 "$before" - | cut -c4-)
|
|
||||||
if [ -n "$generated" ]; then
|
|
||||||
printf '%s\n' "$report" | grep -v 'skipped (in sync)' >&2
|
|
||||||
printf '%s\n' "$generated" | xargs git add --
|
|
||||||
fi
|
|
||||||
@@ -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,57 +5,24 @@ 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) and
|
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit),
|
||||||
# CLOUDFLARE_ACCOUNT_ID.
|
# CLOUDFLARE_ACCOUNT_ID, and OPENROUTER_API_KEY (the docs assistant's
|
||||||
|
# POST /ask; the Actions secret is the one copy, mirrored into the Worker).
|
||||||
deploy-api:
|
deploy-api:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -68,6 +35,13 @@ jobs:
|
|||||||
# DST cron guards must hold before the Worker ships.
|
# DST cron guards must hold before the Worker ships.
|
||||||
- run: bun run api:test
|
- run: bun run api:test
|
||||||
|
|
||||||
|
- run: printf %s "$OPENROUTER_API_KEY" | bunx wrangler secret put OPENROUTER_API_KEY
|
||||||
|
working-directory: apps/api
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||||
|
|
||||||
- run: bun run api:deploy
|
- run: bun run api:deploy
|
||||||
env:
|
env:
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
|||||||
+1
-3
@@ -1,5 +1,6 @@
|
|||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
|
.openlogs
|
||||||
tmp/
|
tmp/
|
||||||
*.log
|
*.log
|
||||||
.crush
|
.crush
|
||||||
@@ -36,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: 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 (above the title) is rendered, not written: the `PageHeader` slot (`components/ProblemBadges.astro`, wired in `components.ts`) 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 (accent), any other tag to its guide page `/guides/<slug>` (neutral; LeetCode's problem list if the guide is gone), and the video as a red `Explanation` badge. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), read by both that component and sync. `content/guides/<slug>.mdx` (the `Guides` header tab) is one page per tag outside that map: sync creates a placeholder when the file is missing or empty and never touches it again (hand-owned; a guide whose tag no page carries any more is left alone), with frontmatter `list:` — the tag's LeetCode problem list, rendered by the same slot 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; `ProgressDashboard`, `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, 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
|
||||||
@@ -107,24 +109,24 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
|||||||
## Important Files
|
## Important Files
|
||||||
|
|
||||||
- `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`.
|
- `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/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/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
|
||||||
|
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
|
||||||
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
- `apps/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>/`, both `meta.ts` script-owned and rewritten every run (phase: `Phase N: Name`, `order: N`; topic: label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`). 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` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, per-topic temperature, gates, the 14-day due queue, and `heat` (every campaign day, zeros and future days included — the docs page windows the tail off it rather than asking a second question). Two readers consume it, the docs island and `bun run stats`, so its shape is both their contract: change all three together. `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 fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`).
|
- `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. The **one** exception is the digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend this exception to any other module.
|
- **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>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw
|
GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw
|
||||||
WEBHOOK_SECRET=random_hex_matching_the_repo_webhook
|
WEBHOOK_SECRET=random_hex_matching_the_repo_webhook
|
||||||
LINK_KEY=random_hex_signing_one_tap_links
|
LINK_KEY=random_hex_signing_one_tap_links
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1_key_for_the_docs_assistant
|
||||||
+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] } }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* POST /ask — the docs assistant's backend.
|
||||||
|
*
|
||||||
|
* The docs are a static GitHub Pages site, and Blume's own assistant route is
|
||||||
|
* server-rendered (it reads the provider key per request), so the site points
|
||||||
|
* `ai.assistant.endpoint` here instead. This speaks the Blume island's wire
|
||||||
|
* contract (`blume/components/islands/hooks.ts`):
|
||||||
|
*
|
||||||
|
* - request: `{ messages: [{ role, content }], page: { path } }` as JSON,
|
||||||
|
* `path` the base-stripped route the reader is on;
|
||||||
|
* - response: the answer as a plain UTF-8 text stream; any non-2xx shows the
|
||||||
|
* island's error notice, and a 503 whose body starts "The assistant is not
|
||||||
|
* configured" shows that body verbatim.
|
||||||
|
*
|
||||||
|
* Validation matches Blume's generated route: 1–40 user/assistant messages of
|
||||||
|
* string content, ≤ 24 000 chars serialized, a 64 KB body, and only role and
|
||||||
|
* content ever reach the model, so the endpoint cannot be repurposed as an
|
||||||
|
* open LLM proxy with a caller's own system prompt.
|
||||||
|
*
|
||||||
|
* Grounding reads what the site already publishes: `llms-full.txt`, every
|
||||||
|
* page's Markdown under `# Title` / `Source: <url>`. The page the reader is on
|
||||||
|
* goes in first, then the best keyword matches, within Blume's default budget
|
||||||
|
* (6 pages, 2 000 chars each, 10 000 total), under Blume's grounded system
|
||||||
|
* prompt — answer only from the excerpts, cite pages as `[Title](/route)`.
|
||||||
|
*
|
||||||
|
* The model call is OpenRouter's OpenAI-compatible chat endpoint over plain
|
||||||
|
* `fetch` (no SDK — see the repo's near-zero-dependency rule), streamed, with
|
||||||
|
* `reasoning.effort: "none"`, for the model the `ASK_MODEL` var names (a
|
||||||
|
* wrangler.jsonc change swaps it); its SSE deltas are re-emitted as bare text. The
|
||||||
|
* reader closing the panel aborts the request, which aborts the model call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BODY_LIMIT = 64 * 1024;
|
||||||
|
const MAX_MESSAGES = 40;
|
||||||
|
const MAX_CHARS = 24_000;
|
||||||
|
|
||||||
|
const MAX_RESULTS = 6;
|
||||||
|
const EXCERPT_CHARS = 2000;
|
||||||
|
const CONTEXT_BUDGET = 10_000;
|
||||||
|
/** Lead-in kept before the matched region of a long page. */
|
||||||
|
const EXCERPT_LEAD = 160;
|
||||||
|
/** How long one isolate reuses the parsed corpus (the edge caches the fetch as long). */
|
||||||
|
const CORPUS_TTL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
"You are a helpful documentation assistant for this project, a LeetCode interview-prep campaign: solved problems grouped by topic, with guides to the techniques behind them. Answer the user's question using ONLY the documentation excerpts below. Each excerpt is headed by its page as `## Page Title (/route)`. If the answer is not covered by the excerpts, say you don't know and suggest where in the docs to look — do not invent details. Always cite the pages you drew from, and write every citation as a Markdown link to that page using its route, e.g. [Page Title](/route).";
|
||||||
|
|
||||||
|
/** Words that match everything and rank nothing. */
|
||||||
|
const STOPWORDS = new Set(
|
||||||
|
"a an and are as at be but by can do does for from how i if in is it its me my of on or so that the this to was what when where which who why with you your".split(
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface AskMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
title: string;
|
||||||
|
route: string;
|
||||||
|
body: string;
|
||||||
|
/** Lower-cased title + body, searched per query term. */
|
||||||
|
text: string;
|
||||||
|
titleText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── corpus ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let corpus: { at: number; sections: Section[] } | undefined;
|
||||||
|
|
||||||
|
/** `llms-full.txt` split into pages, cached per isolate for `CORPUS_TTL_MS`. */
|
||||||
|
async function sections(docsUrl: string): Promise<Section[]> {
|
||||||
|
if (corpus && Date.now() - corpus.at < CORPUS_TTL_MS) return corpus.sections;
|
||||||
|
const res = await fetch(`${docsUrl}/llms-full.txt`, {
|
||||||
|
cf: { cacheTtl: CORPUS_TTL_MS / 1000, cacheEverything: true },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`GET llms-full.txt -> ${res.status}`);
|
||||||
|
const full = await res.text();
|
||||||
|
const heads = [...full.matchAll(/^# (.+)\nSource: (\S+)$/gm)];
|
||||||
|
const out = heads.map((head, i): Section => {
|
||||||
|
const start = head.index + head[0].length;
|
||||||
|
const end = heads[i + 1]?.index ?? full.length;
|
||||||
|
const body = full.slice(start, end).replace(/\n-{3,}\s*$/, "").trim();
|
||||||
|
const title = head[1]!.trim();
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
route: new URL(head[2]!).pathname.replace(/(.)\/+$/, "$1"),
|
||||||
|
body,
|
||||||
|
text: `${title}\n${body}`.toLowerCase(),
|
||||||
|
titleText: title.toLowerCase(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
corpus = { at: Date.now(), sections: out };
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── retrieval ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function terms(text: string): string[] {
|
||||||
|
return [...new Set(text.toLowerCase().match(/[a-z0-9][a-z0-9+#-]*/g) ?? [])].filter(
|
||||||
|
(t) => !STOPWORDS.has(t) && (t.length > 1 || /\d/.test(t)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function occurrences(haystack: string, needle: string): number {
|
||||||
|
let count = 0;
|
||||||
|
for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pages to inject, best first: term frequency damped by log and weighted
|
||||||
|
* by rarity across pages (idf), a title hit counting triple — so "217" or
|
||||||
|
* "sliding window" lands on the page named that rather than every page that
|
||||||
|
* mentions it in passing.
|
||||||
|
*/
|
||||||
|
function rank(all: Section[], query: string[]): Section[] {
|
||||||
|
const idf = new Map(
|
||||||
|
query.map((t) => [t, Math.log(1 + all.length / (1 + all.filter((s) => s.text.includes(t)).length))]),
|
||||||
|
);
|
||||||
|
return all
|
||||||
|
.map((section) => {
|
||||||
|
let score = 0;
|
||||||
|
for (const t of query) {
|
||||||
|
const tf = occurrences(section.text, t);
|
||||||
|
if (tf) score += idf.get(t)! * (Math.log(1 + tf) + (section.titleText.includes(t) ? 3 : 0));
|
||||||
|
}
|
||||||
|
return { section, score };
|
||||||
|
})
|
||||||
|
.filter((r) => r.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, MAX_RESULTS)
|
||||||
|
.map((r) => r.section);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page whole when it fits, else `chars` from just before its first query hit. */
|
||||||
|
function excerpt(section: Section, query: string[], chars: number): string {
|
||||||
|
if (section.body.length <= chars) return section.body;
|
||||||
|
const lower = section.body.toLowerCase();
|
||||||
|
const hits = query.map((t) => lower.indexOf(t)).filter((i) => i !== -1);
|
||||||
|
const start = hits.length ? Math.max(0, Math.min(...hits) - EXCERPT_LEAD) : 0;
|
||||||
|
return `${start > 0 ? "…" : ""}${section.body.slice(start, start + chars)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The system prompt: the current page, then the ranked pages, within the budget. */
|
||||||
|
async function instructions(env: Env, messages: AskMessage[], path: string | undefined): Promise<string> {
|
||||||
|
const all = await sections(env.DOCS_URL);
|
||||||
|
// A follow-up ("and in Python?") leans on the question before it.
|
||||||
|
const asked = messages.filter((m) => m.role === "user").slice(-2).map((m) => m.content);
|
||||||
|
const query = terms(asked.join(" "));
|
||||||
|
const current = path ? all.find((s) => s.route === path) : undefined;
|
||||||
|
const picked = [...(current ? [current] : []), ...rank(all, query).filter((s) => s !== current)];
|
||||||
|
const parts: string[] = [];
|
||||||
|
let budget = CONTEXT_BUDGET;
|
||||||
|
for (const section of picked) {
|
||||||
|
if (budget < 200) break;
|
||||||
|
const text = excerpt(section, query, Math.min(EXCERPT_CHARS, budget));
|
||||||
|
parts.push(`## ${section.title} (${section.route})\n\n${text}`);
|
||||||
|
budget -= text.length;
|
||||||
|
}
|
||||||
|
return parts.length ? `${SYSTEM_PROMPT}\n\n# Documentation excerpts\n\n${parts.join("\n\n")}` : SYSTEM_PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── request handling ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The validated conversation, or a reason it was turned away. */
|
||||||
|
function readMessages(body: unknown): AskMessage[] | undefined {
|
||||||
|
if (!body || typeof body !== "object" || !("messages" in body) || !Array.isArray(body.messages)) return;
|
||||||
|
const raw: unknown[] = body.messages;
|
||||||
|
if (raw.length === 0 || raw.length > MAX_MESSAGES || JSON.stringify(raw).length > MAX_CHARS) return;
|
||||||
|
const messages: AskMessage[] = [];
|
||||||
|
for (const m of raw) {
|
||||||
|
if (!m || typeof m !== "object" || !("role" in m) || !("content" in m)) return;
|
||||||
|
if ((m.role !== "user" && m.role !== "assistant") || typeof m.content !== "string") return;
|
||||||
|
messages.push({ role: m.role, content: m.content });
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPath(body: unknown): string | undefined {
|
||||||
|
if (!body || typeof body !== "object" || !("page" in body)) return;
|
||||||
|
const page = body.page;
|
||||||
|
if (!page || typeof page !== "object" || !("path" in page) || typeof page.path !== "string") return;
|
||||||
|
return page.path.replace(/(.)\/+$/, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OpenRouter's SSE (`data: {…choices[0].delta.content…}`) → the answer's bare text. */
|
||||||
|
function textStream(sse: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let buffer = "";
|
||||||
|
return sse.pipeThrough(
|
||||||
|
new TransformStream<Uint8Array, Uint8Array>({
|
||||||
|
transform(chunk, controller) {
|
||||||
|
buffer += decoder.decode(chunk, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith("data: ")) continue; // `: OPENROUTER PROCESSING` keep-alives
|
||||||
|
const data = line.slice(6).trim();
|
||||||
|
if (data === "[DONE]") continue;
|
||||||
|
let event: unknown;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event && typeof event === "object" && "error" in event) {
|
||||||
|
// After the 200: the stream just ends, and the island shows its
|
||||||
|
// error notice for an empty answer.
|
||||||
|
console.error(`ask: provider error mid-stream: ${JSON.stringify(event.error)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!event || typeof event !== "object" || !("choices" in event) || !Array.isArray(event.choices)) continue;
|
||||||
|
const choice: unknown = event.choices[0];
|
||||||
|
if (!choice || typeof choice !== "object" || !("delta" in choice)) continue;
|
||||||
|
const delta = choice.delta;
|
||||||
|
if (!delta || typeof delta !== "object" || !("content" in delta)) continue;
|
||||||
|
const content = delta.content;
|
||||||
|
if (typeof content === "string" && content) controller.enqueue(encoder.encode(content));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAsk(request: Request, env: Env): Promise<Response> {
|
||||||
|
// The docs serve from the custom domain and the Pages origin; reflect the
|
||||||
|
// requesting origin only when it is on the allowlist (as /api/stats does).
|
||||||
|
const allowed = env.DOCS_ORIGIN.split(",").map((o) => o.trim());
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
const cors = {
|
||||||
|
"access-control-allow-origin": origin && allowed.includes(origin) ? origin : allowed[0]!,
|
||||||
|
"access-control-allow-methods": "POST",
|
||||||
|
"access-control-allow-headers": "content-type",
|
||||||
|
"access-control-max-age": "86400",
|
||||||
|
vary: "Origin",
|
||||||
|
};
|
||||||
|
const text = (body: string, status: number) =>
|
||||||
|
new Response(body, { status, headers: { ...cors, "content-type": "text/plain; charset=utf-8" } });
|
||||||
|
|
||||||
|
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
|
||||||
|
if (request.method !== "POST") return text("POST only.", 405);
|
||||||
|
|
||||||
|
const raw = await request.arrayBuffer();
|
||||||
|
if (raw.byteLength > BODY_LIMIT) return text("Request too large: the body must be at most 64 KB.", 413);
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(new TextDecoder().decode(raw));
|
||||||
|
} catch {
|
||||||
|
body = undefined;
|
||||||
|
}
|
||||||
|
const messages = readMessages(body);
|
||||||
|
if (!messages) return text("Invalid request: send 1-40 user/assistant messages with string content.", 400);
|
||||||
|
if (!env.OPENROUTER_API_KEY) return text("The assistant is not configured: set OPENROUTER_API_KEY.", 503);
|
||||||
|
|
||||||
|
let system: string;
|
||||||
|
try {
|
||||||
|
system = await instructions(env, messages, readPath(body));
|
||||||
|
} catch (err) {
|
||||||
|
// Ungrounded beats unanswered: the prompt still forbids inventing details.
|
||||||
|
console.warn(`ask: grounding unavailable: ${err}`);
|
||||||
|
system = SYSTEM_PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: env.ASK_MODEL,
|
||||||
|
stream: true,
|
||||||
|
reasoning: { effort: "none" },
|
||||||
|
messages: [{ role: "system", content: system }, ...messages],
|
||||||
|
}),
|
||||||
|
signal: request.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
console.error(`ask: POST /chat/completions -> ${res.status} ${await res.text().catch(() => "")}`);
|
||||||
|
return text("Failed to generate a response.", res.status === 429 ? 429 : 502);
|
||||||
|
}
|
||||||
|
return new Response(textStream(res.body), {
|
||||||
|
headers: { ...cors, "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
+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 };
|
||||||
}
|
}
|
||||||
+17
-4
@@ -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
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
CAMPAIGN_DAYS,
|
CAMPAIGN_DAYS,
|
||||||
|
MISS_WHERE,
|
||||||
type ProblemRow,
|
type ProblemRow,
|
||||||
RESERVED,
|
RESERVED,
|
||||||
REVIEW_CAP,
|
REVIEW_CAP,
|
||||||
@@ -84,8 +86,7 @@ async function openMisses(db: D1Database): Promise<ProblemRow[]> {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT p.* FROM problems p
|
`SELECT p.* FROM problems p
|
||||||
JOIN attempts a ON a.lc_number = p.lc_number
|
JOIN attempts a ON a.lc_number = p.lc_number
|
||||||
WHERE a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
|
WHERE ${MISS_WHERE}
|
||||||
AND a.result = 'fail' AND p.stage != 'retired'
|
|
||||||
ORDER BY a.date, p.lc_number`,
|
ORDER BY a.date, p.lc_number`,
|
||||||
)
|
)
|
||||||
.all<ProblemRow>();
|
.all<ProblemRow>();
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
|
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
|
||||||
* admin routes); everything else is read-only public.
|
* admin routes); everything else is read-only public.
|
||||||
*/
|
*/
|
||||||
|
import { handleAsk } from "./ask.ts";
|
||||||
import { reconcileCatalog } from "./catalog.ts";
|
import { reconcileCatalog } from "./catalog.ts";
|
||||||
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
|
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
|
||||||
import { sendDigest } from "./digest.ts";
|
import { sendDigest } from "./digest.ts";
|
||||||
@@ -17,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,
|
||||||
@@ -322,6 +324,8 @@ export default {
|
|||||||
if (path.startsWith("/admin/") && request.method === "POST") {
|
if (path.startsWith("/admin/") && request.method === "POST") {
|
||||||
return await handleAdmin(request, env, path);
|
return await handleAdmin(request, env, path);
|
||||||
}
|
}
|
||||||
|
// The docs assistant (the static Pages site's `ai.assistant.endpoint`).
|
||||||
|
if (path === "/ask") return await handleAsk(request, env);
|
||||||
|
|
||||||
if (path === "/chart/progress.svg") {
|
if (path === "/chart/progress.svg") {
|
||||||
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
|
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
|
||||||
@@ -330,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 ?? [])]) {
|
||||||
|
|||||||
+42
-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 ───────────────────────────────────────
|
||||||
@@ -310,6 +334,15 @@ export async function lookupProblems(
|
|||||||
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||||
AND (defer_until IS NULL OR defer_until <= ?1)`;
|
AND (defer_until IS NULL OR defer_until <= ?1)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an open miss means, in one place: a problem `p` whose most recent
|
||||||
|
* attempt (highest id) failed and that has not retired. The sweep day works
|
||||||
|
* these and the stats' "attempting" count is how many there are, so the two
|
||||||
|
* cannot disagree. Expects `problems p` joined to `attempts a` on lc_number.
|
||||||
|
*/
|
||||||
|
export const MISS_WHERE = `a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
|
||||||
|
AND a.result = 'fail' AND p.stage != 'retired'`;
|
||||||
|
|
||||||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
|
|||||||
+90
-3
@@ -8,12 +8,17 @@
|
|||||||
* srs.ts turns a topic's days-since-last-attempt into a band, so "which
|
* srs.ts turns a topic's days-since-last-attempt into a band, so "which
|
||||||
* concepts have gone cold" has exactly one definition and the dashboard is
|
* concepts have gone cold" has exactly one definition and the dashboard is
|
||||||
* free to colour it however it likes.
|
* free to colour it however it likes.
|
||||||
|
*
|
||||||
|
* `recent`, `attempting` and `year` feed the docs page's LeetCode-style cards
|
||||||
|
* (recently solved, the solved ring, the heatmap and the submissions chart).
|
||||||
|
* They are additive: the terminal dashboard ignores them.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
CAMPAIGN_DAYS,
|
CAMPAIGN_DAYS,
|
||||||
CAMPAIGN_START,
|
CAMPAIGN_START,
|
||||||
DIFFICULTIES,
|
DIFFICULTIES,
|
||||||
DUE_WHERE,
|
DUE_WHERE,
|
||||||
|
MISS_WHERE,
|
||||||
addDays,
|
addDays,
|
||||||
campaignDay,
|
campaignDay,
|
||||||
campaignWeek,
|
campaignWeek,
|
||||||
@@ -54,6 +59,12 @@ interface TopicStat {
|
|||||||
temperature: Temperature;
|
temperature: Temperature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Days in `year`: the 365 ending today, which always covers the current calendar year to date. */
|
||||||
|
const YEAR_DAYS = 365;
|
||||||
|
|
||||||
|
/** How many problems `recent` lists. */
|
||||||
|
const RECENT_LIMIT = 10;
|
||||||
|
|
||||||
async function buildTopics(db: D1Database, today: string): Promise<TopicStat[]> {
|
async function buildTopics(db: D1Database, today: string): Promise<TopicStat[]> {
|
||||||
// Three shapes, three statements: batch() carries one row type, and casting
|
// Three shapes, three statements: batch() carries one row type, and casting
|
||||||
// its results apart would cost more than the round trips it saves.
|
// its results apart would cost more than the round trips it saves.
|
||||||
@@ -195,9 +206,9 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
|||||||
);
|
);
|
||||||
const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 }));
|
const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 }));
|
||||||
|
|
||||||
// Attempts per campaign day, every day present. This is the heatmap's data
|
// Attempts per campaign day, every day present. This is the README heatmap's
|
||||||
// model — the docs page reads the tail of it for "last 7 days" rather than
|
// data model — the terminal dashboard lays it out by campaign week and reads
|
||||||
// asking a second question that could answer differently.
|
// its tail for "last 14 days" rather than asking a second question.
|
||||||
const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
|
const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
|
||||||
const { results: heatRows } = await db
|
const { results: heatRows } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -215,16 +226,92 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
|
|||||||
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 };
|
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The ten problems most recently solved, dated by each one's FIRST passing
|
||||||
|
// attempt — when it was completed, not when it was last re-solved (LeetCode's
|
||||||
|
// "Recent AC"). Same-day ties fall back to log order.
|
||||||
|
const { results: recentRows } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.lc_number AS lc, p.title AS title, p.difficulty AS difficulty,
|
||||||
|
MIN(a.date) AS date, MIN(a.id) AS seq
|
||||||
|
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||||
|
WHERE a.result = 'pass'
|
||||||
|
GROUP BY p.lc_number
|
||||||
|
ORDER BY date DESC, seq DESC
|
||||||
|
LIMIT ?1`,
|
||||||
|
)
|
||||||
|
.bind(RECENT_LIMIT)
|
||||||
|
.all<{ lc: number; title: string; difficulty: string; date: string; seq: number }>();
|
||||||
|
const recent = recentRows.map(({ lc, title, difficulty, date }) => ({ lc, title, difficulty, date }));
|
||||||
|
|
||||||
|
// Problems still standing on a failure — the sweep day's miss log, counted.
|
||||||
|
const missRow = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM problems p
|
||||||
|
JOIN attempts a ON a.lc_number = p.lc_number
|
||||||
|
WHERE ${MISS_WHERE}`,
|
||||||
|
)
|
||||||
|
.first<{ n: number }>();
|
||||||
|
|
||||||
|
// The past year, one entry per day (zeros included), split by difficulty two
|
||||||
|
// ways: every logged attempt, and every first solve — the same first-pass
|
||||||
|
// date `recent` uses. `heat` stays campaign-scoped (the terminal dashboard
|
||||||
|
// lays it out by campaign week); this is the calendar-scoped view the docs
|
||||||
|
// heatmap and submissions chart window and bucket for themselves.
|
||||||
|
const yearStart = addDays(today, -(YEAR_DAYS - 1));
|
||||||
|
const { results: attemptRows } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.date AS date, p.difficulty AS difficulty,
|
||||||
|
COUNT(*) AS n,
|
||||||
|
SUM(CASE WHEN a.result = 'pass' THEN 1 ELSE 0 END) AS passes
|
||||||
|
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||||
|
WHERE a.date BETWEEN ?1 AND ?2
|
||||||
|
GROUP BY a.date, p.difficulty`,
|
||||||
|
)
|
||||||
|
.bind(yearStart, today)
|
||||||
|
.all<{ date: string; difficulty: string; n: number; passes: number | null }>();
|
||||||
|
const { results: solvedRows } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT date, difficulty, COUNT(*) AS n
|
||||||
|
FROM (SELECT MIN(a.date) AS date, p.difficulty AS difficulty
|
||||||
|
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
|
||||||
|
WHERE a.result = 'pass'
|
||||||
|
GROUP BY p.lc_number)
|
||||||
|
WHERE date BETWEEN ?1 AND ?2
|
||||||
|
GROUP BY date, difficulty`,
|
||||||
|
)
|
||||||
|
.bind(yearStart, today)
|
||||||
|
.all<{ date: string; difficulty: string; n: number }>();
|
||||||
|
const year = Array.from({ length: YEAR_DAYS }, (_, i) => ({
|
||||||
|
date: addDays(yearStart, i),
|
||||||
|
passes: 0,
|
||||||
|
attempts: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])),
|
||||||
|
solved: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])),
|
||||||
|
}));
|
||||||
|
const yearIndex = new Map(year.map((d, i) => [d.date, i]));
|
||||||
|
for (const row of attemptRows) {
|
||||||
|
const day = year[yearIndex.get(row.date) ?? -1];
|
||||||
|
if (!day || !(row.difficulty in day.attempts)) continue;
|
||||||
|
day.attempts[row.difficulty] = row.n;
|
||||||
|
day.passes += row.passes ?? 0;
|
||||||
|
}
|
||||||
|
for (const row of solvedRows) {
|
||||||
|
const day = year[yearIndex.get(row.date) ?? -1];
|
||||||
|
if (day && row.difficulty in day.solved) day.solved[row.difficulty] = row.n;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generated: new Date().toISOString(),
|
generated: new Date().toISOString(),
|
||||||
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
||||||
phases: [...phases.values()],
|
phases: [...phases.values()],
|
||||||
ladder,
|
ladder,
|
||||||
difficulty,
|
difficulty,
|
||||||
|
attempting: missRow?.n ?? 0,
|
||||||
topics: await buildTopics(db, today),
|
topics: await buildTopics(db, today),
|
||||||
gates,
|
gates,
|
||||||
streak: await streak(db, today),
|
streak: await streak(db, today),
|
||||||
queue,
|
queue,
|
||||||
heat,
|
heat,
|
||||||
|
recent,
|
||||||
|
year,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -34,8 +34,13 @@
|
|||||||
"TO_EMAIL": "prnk28@gmail.com",
|
"TO_EMAIL": "prnk28@gmail.com",
|
||||||
"DOCS_URL": "https://lc.prd.lk",
|
"DOCS_URL": "https://lc.prd.lk",
|
||||||
"DOCS_ORIGIN": "https://lc.prd.lk,https://prdlk.github.io",
|
"DOCS_ORIGIN": "https://lc.prd.lk,https://prdlk.github.io",
|
||||||
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
|
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev",
|
||||||
|
// The docs assistant's OpenRouter model (POST /ask). `:free` variants share
|
||||||
|
// an upstream pool and answer 429 when it is saturated.
|
||||||
|
"ASK_MODEL": "qwen/qwen3.8-27b:free"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
|
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
|
||||||
|
// OPENROUTER_API_KEY (the docs assistant, POST /ask) is the repo's Actions
|
||||||
|
// secret of that name, put by deploy.yml before every deploy.
|
||||||
}
|
}
|
||||||
+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")}` : ""),
|
||||||
);
|
);
|
||||||
+148
-132
@@ -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,47 +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 `PageHeader` slot
|
* 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 above the title.
|
* `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; the tag's
|
* badge after the others; an unplanned warm-up has no catalog issue and no
|
||||||
* badge on a problem page links to it.
|
* key. Like `topics:` it is filled once — on create, or on any run that
|
||||||
|
* finds it missing — and the issue list is fetched only on such a run
|
||||||
|
* (never on a sync with nothing new); offline, or with no `gh` auth, the
|
||||||
|
* page is written without it and the next sync retries. Delete the key to
|
||||||
|
* refetch it.
|
||||||
*
|
*
|
||||||
* 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 with its own
|
* The phase folder is a Blume `(group)` — a sidebar section adding no route
|
||||||
* script-owned `meta.ts`, adding no route segment — while the topic folder is
|
* segment — while the topic folder is a real segment
|
||||||
* a real segment (`/tree-dfs/invert-binary-tree`), so a hand-written
|
* (`/tree-dfs/invert-binary-tree`), so a hand-written `index.mdx` in it is the
|
||||||
* `index.mdx` in it is the topic's landing page at `/tree-dfs` and Blume links
|
* topic's landing page at `/tree-dfs` and Blume links the sidebar group row to
|
||||||
* the sidebar group row to it. Sync never writes or moves an `index.mdx`, and
|
* it. The topic's problem pages sit one level down in a `(problems)` group,
|
||||||
* never removes a folder that still holds one. An unplanned category group has
|
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either,
|
||||||
* no phase and sits at the content root.
|
* and Blume strips the `<num>_` prefix from the route while sorting by it, so
|
||||||
|
* the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves
|
||||||
|
* an `index.mdx`, and never removes a folder that still holds one. An
|
||||||
|
* unplanned category group has no phase and sits at the docs/ root.
|
||||||
|
*
|
||||||
|
* `docs/site.json` is wholly script-owned and rewritten whenever it differs:
|
||||||
|
* - `folders`: Blume folder meta keyed by folder path under docs/ — exactly
|
||||||
|
* the key a `meta.ts` in that folder would have. A phase is `N. Name` in
|
||||||
|
* phase order; a topic is its exact name (the folder slug loses `/` and
|
||||||
|
* case), curriculum order, `TOPIC_ICONS` icon and `display: "page"`, so it
|
||||||
|
* is one sidebar row drilling into its own panel. `(problems)` has none:
|
||||||
|
* Blume's humanized "Problems" is the label.
|
||||||
|
* - `redirects`: one `{ from: "/<num>", to: "/<topic>/<slug>" }` per page, in
|
||||||
|
* number order — exact root-relative paths; Blume prepends the deployment
|
||||||
|
* base itself.
|
||||||
*
|
*
|
||||||
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
|
* 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";
|
||||||
|
|
||||||
@@ -309,25 +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)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Blume `(group)` meta for a phase: the overview page's heading, in phase order. */
|
/** A sidebar group's Blume folder meta, as `docs/site.json` carries it. */
|
||||||
function phaseMeta(phase: Phase): string {
|
interface FolderMeta {
|
||||||
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(`Phase ${phase.order}: ${phase.name}`)}, order: ${phase.order} });\n`;
|
title: string;
|
||||||
|
order: number;
|
||||||
|
icon?: string;
|
||||||
|
display?: "page";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 ──────────────────────────────────────────
|
||||||
@@ -374,38 +403,50 @@ 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 `PageHeader` slot. 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`;
|
* The problem's issue URL, or undefined (warned when GitHub answered but has
|
||||||
|
* no such issue). Only a problem the README plan lists has a catalog issue;
|
||||||
|
* an unplanned warm-up never will, so it is not looked up — otherwise its
|
||||||
|
* missing key would re-list the issues and re-warn on every sync.
|
||||||
|
*/
|
||||||
|
async function issueUrl(sol: Solution): Promise<string | undefined> {
|
||||||
|
if (!topics.has(sol.num)) return undefined;
|
||||||
|
const urls = await problemIssues();
|
||||||
|
const url = urls?.get(sol.num);
|
||||||
|
if (urls && !url) console.warn(`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
// ── inline-code formatting heuristics ────────────────────────────
|
// ── inline-code formatting heuristics ────────────────────────────
|
||||||
|
|
||||||
@@ -511,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*$/, "") ?? "";
|
||||||
@@ -523,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}'`,
|
||||||
@@ -618,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
|
||||||
@@ -646,25 +697,23 @@ 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
|
|
||||||
|
|
||||||
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 });
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const page = await Bun.file(join(DOCS, existing)).text();
|
const page = await Bun.file(join(DOCS, existing)).text();
|
||||||
@@ -675,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));
|
||||||
@@ -687,74 +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)"]);
|
||||||
for (const tag of frontmatterTopics(next)) tags.set(tag.slug, tag);
|
|
||||||
} 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"]);
|
||||||
for (const tag of frontmatterTopics(page)) tags.set(tag.slug, tag);
|
|
||||||
}
|
}
|
||||||
// The route drops the phase `(group)` and the page's number prefix.
|
// The route drops the phase `(group)`, `(problems)` and the number prefix.
|
||||||
redirects.push(` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`);
|
redirects.push({ from: `/${sol.num}`, to: `/${slugify(topic.name)}/${sol.slug}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Script-owned short links, one per page; imported by blume.config.ts.
|
// The site's one generated input besides the pages; script-owned, rewritten
|
||||||
|
// only when it differs. Folders sorted by path so a diff reads by location.
|
||||||
{
|
{
|
||||||
const module = [
|
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 whose tag no
|
// that emptied too (`(problems)`, its topic, the phase). A folder that still
|
||||||
// page carries any more is left alone: it is hand-owned.
|
// holds anything (a hand-written index.mdx, another topic) stops the climb.
|
||||||
const guideTags = [...tags.values()]
|
|
||||||
.filter((tag) => !TOPIC_LANDINGS[tag.slug])
|
|
||||||
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
||||||
for (const tag of guideTags) {
|
|
||||||
const file = Bun.file(join(DOCS, "guides", `${tag.slug}.mdx`));
|
|
||||||
if ((await file.exists()) && file.size > 0) continue;
|
|
||||||
await Bun.write(file, guidePage(tag));
|
|
||||||
report.push([`tag ${tag.slug}`, `guides/${tag.slug}.mdx`, "created"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Script-owned folder metas — one per live phase and topic folder, plus
|
|
||||||
// guides/ — rewritten only when they differ.
|
|
||||||
const metas: [label: string, dir: string, meta: string][] = [
|
|
||||||
...[...phases].map(([dir, phase]): [string, string, string] => [`phase ${phase.order}`, dir, phaseMeta(phase)]),
|
|
||||||
...[...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 `PageHeader` slot
|
* 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,163 +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 },
|
|
||||||
},
|
|
||||||
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,17 +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",
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
// Problem pages' topic + explanation badges, from `topics:` / `video:` frontmatter.
|
|
||||||
PageHeader: "./components/ProblemBadges.astro",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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,92 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* The `PageHeader` slot: the badge row above a page's title. Blume hands the
|
|
||||||
* slot only the route, so the page's frontmatter is 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
|
|
||||||
* nothing.
|
|
||||||
*
|
|
||||||
* - `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 reads accent; any other tag links to its guide
|
|
||||||
* page, `/guides/<slug>` (sync creates one per such tag), and reads neutral.
|
|
||||||
* - `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.
|
|
||||||
*
|
|
||||||
* A tag naming two campaign topics (`Depth-First Search` is both Tree DFS and
|
|
||||||
* Graph DFS) links to the page's own topic — its route's first segment — when
|
|
||||||
* that is one of them; otherwise DFS/BFS prefer the tree topic for a problem
|
|
||||||
* LeetCode also tags `Tree` and the graph topic for the rest. A route the site
|
|
||||||
* does not serve (a landing or guide page removed) falls back to LeetCode's
|
|
||||||
* problem list for the tag, so a badge never links to a 404.
|
|
||||||
*/
|
|
||||||
import { getEntry } from "astro:content";
|
|
||||||
import data from "blume:data";
|
|
||||||
import Badge from "blume/components/content/Badge.astro";
|
|
||||||
import { withBase } from "blume/components/islands/base-path.ts";
|
|
||||||
import { TOPIC_LANDINGS } from "../topic-landings.ts";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
route: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TopicTag {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { route } = Astro.props;
|
|
||||||
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(data.routes.map((r: { path: string }) => r.path));
|
|
||||||
const own = `/${route.split("/")[1]}`;
|
|
||||||
const prefer = tags.some((t) => t.slug === "tree") ? "/tree-" : "/graph-";
|
|
||||||
|
|
||||||
const badges = tags.map((tag) => {
|
|
||||||
const landings = (TOPIC_LANDINGS[tag.slug] ?? []).filter((path) => served.has(path));
|
|
||||||
const landing = landings.includes(own) ? own : (landings.find((p) => p.startsWith(prefer)) ?? landings[0]);
|
|
||||||
if (landing) return { name: tag.name, href: withBase(landing), accent: true, external: false };
|
|
||||||
const guide = `/guides/${tag.slug}`;
|
|
||||||
if (served.has(guide)) return { name: tag.name, href: withBase(guide), accent: false, external: false };
|
|
||||||
return { name: tag.name, href: `https://leetcode.com/problem-list/${tag.slug}/`, accent: false, external: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
const linkClass = "no-underline transition-opacity hover:opacity-80";
|
|
||||||
---
|
|
||||||
|
|
||||||
{
|
|
||||||
(badges.length > 0 || video || list) && (
|
|
||||||
<div class="not-prose mx-auto mb-4 flex max-w-content 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 variant={b.accent ? "accent" : "default"}>{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">
|
|
||||||
Explanation
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{list && (
|
|
||||||
<a href={list} target="_blank" rel="noopener" class={linkClass}>
|
|
||||||
<Badge variant="warning" icon="list">
|
|
||||||
LeetCode list
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,3 +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,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Hash Table", order: 1, icon: "hash", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Phase 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: "Phase 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: "Phase 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: "Phase 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,5 +0,0 @@
|
|||||||
---
|
|
||||||
title: Greedy
|
|
||||||
description: 'The best local choice is made at each step and is not changed later.'
|
|
||||||
hidden: true
|
|
||||||
---
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
title: Intervals
|
|
||||||
description: 'Ranges with a start and an end are sorted, and then merged, counted, or scheduled.'
|
|
||||||
hidden: true
|
|
||||||
---
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Phase 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,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Array"
|
|
||||||
list: "https://leetcode.com/problem-list/array/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Array.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Bidirectional Search"
|
|
||||||
list: "https://leetcode.com/problem-list/bidirectional-search/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Bidirectional Search.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Binary Lifting"
|
|
||||||
list: "https://leetcode.com/problem-list/binary-lifting/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Binary Lifting.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Binary Tree"
|
|
||||||
list: "https://leetcode.com/problem-list/binary-tree/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Binary Tree.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Bit Manipulation"
|
|
||||||
list: "https://leetcode.com/problem-list/bit-manipulation/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Bit Manipulation.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Bracket Sequences"
|
|
||||||
list: "https://leetcode.com/problem-list/bracket-sequences/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Bracket Sequences.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Bucket Sort"
|
|
||||||
list: "https://leetcode.com/problem-list/bucket-sort/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Bucket Sort.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Combinatorics"
|
|
||||||
list: "https://leetcode.com/problem-list/combinatorics/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Combinatorics.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Complete Knapsack"
|
|
||||||
list: "https://leetcode.com/problem-list/complete-knapsack/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Complete Knapsack.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Counting"
|
|
||||||
list: "https://leetcode.com/problem-list/counting/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Counting.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Design"
|
|
||||||
list: "https://leetcode.com/problem-list/design/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Design.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Directed Acyclic Graph"
|
|
||||||
list: "https://leetcode.com/problem-list/directed-acyclic-graph/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Directed Acyclic Graph.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Divide and Conquer"
|
|
||||||
list: "https://leetcode.com/problem-list/divide-and-conquer/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Divide and Conquer.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Doubly-Linked List"
|
|
||||||
list: "https://leetcode.com/problem-list/doubly-linked-list/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Doubly-Linked List.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Floyd's Cycle Finding Algorithm"
|
|
||||||
list: "https://leetcode.com/problem-list/floyds-cycle-finding-algorithm/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Floyd's Cycle Finding Algorithm.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Geometry"
|
|
||||||
list: "https://leetcode.com/problem-list/geometry/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Geometry.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Graph Theory"
|
|
||||||
list: "https://leetcode.com/problem-list/graph/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Graph Theory.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "K-D Tree"
|
|
||||||
list: "https://leetcode.com/problem-list/k-d-tree/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged K-D Tree.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Knapsack Problem"
|
|
||||||
list: "https://leetcode.com/problem-list/knapsack-problem/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Knapsack Problem.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Longest Common Subsequence"
|
|
||||||
list: "https://leetcode.com/problem-list/longest-common-subsequence/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Longest Common Subsequence.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Lowest Common Ancestor"
|
|
||||||
list: "https://leetcode.com/problem-list/lowest-common-ancestor/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Lowest Common Ancestor.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Math"
|
|
||||||
list: "https://leetcode.com/problem-list/math/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Math.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Memoization"
|
|
||||||
list: "https://leetcode.com/problem-list/memoization/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Memoization.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Merge Sort"
|
|
||||||
list: "https://leetcode.com/problem-list/merge-sort/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::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: "Guides" });
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Queue"
|
|
||||||
list: "https://leetcode.com/problem-list/queue/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Queue.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Quickselect"
|
|
||||||
list: "https://leetcode.com/problem-list/quickselect/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Quickselect.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Recursion"
|
|
||||||
list: "https://leetcode.com/problem-list/recursion/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Recursion.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Simulation"
|
|
||||||
list: "https://leetcode.com/problem-list/simulation/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Simulation.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Sorting"
|
|
||||||
list: "https://leetcode.com/problem-list/sorting/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Sorting.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "String"
|
|
||||||
list: "https://leetcode.com/problem-list/string/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged String.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Tournament Sort"
|
|
||||||
list: "https://leetcode.com/problem-list/tournament-sort/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Tournament Sort.
|
|
||||||
:::
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Tree"
|
|
||||||
list: "https://leetcode.com/problem-list/tree/"
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Tree.
|
|
||||||
:::
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
title: Progress
|
|
||||||
description: Live SRS campaign dashboard — phases, ladder, review queue, gates, and recent attempts, fetched from the SRS Worker.
|
|
||||||
icon: loader
|
|
||||||
---
|
|
||||||
|
|
||||||
<ProgressDashboard />
|
|
||||||
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user