From ebd229260058e857baebe467c3a53f671ac40006 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Sun, 11 Oct 2026 16:30:45 +0530 Subject: [PATCH] Give each agent's leaderboard its own indexable page The per-agent view lived at /?model= and was noindexed as a re-cut of the home page, though each agent ranks the repos differently. It moves to /model/ with its own title, description and sitemap entry, and the old URL 308s there. Host, search and sort views stay noindex. Also fixes a 500 from /api/badge when ?model= named an Object.prototype key such as "constructor": the check used `in`. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 16 +- README.md | 3 +- app/api/badge/[...slug]/route.ts | 10 +- app/llms.txt/route.ts | 3 +- app/model/[slug]/page.tsx | 100 ++++++++++++ app/page.tsx | 187 +++-------------------- app/repo/[...slug]/page.tsx | 3 +- app/score/[host]/[owner]/[name]/page.tsx | 3 +- app/sitemap.ts | 11 +- components/HostSelect.tsx | 5 +- components/LeaderboardBoard.tsx | 74 +++++++++ components/SearchBar.tsx | 10 +- components/SortSelect.tsx | 5 +- lib/leaderboard-view.ts | 63 ++++++++ lib/utils/leaderboard-href.ts | 48 ++++++ tasks/0.9.0/README.md | 4 + tests/leaderboard-href.test.ts | 30 ++++ 17 files changed, 382 insertions(+), 193 deletions(-) create mode 100644 app/model/[slug]/page.tsx create mode 100644 components/LeaderboardBoard.tsx create mode 100644 lib/leaderboard-view.ts create mode 100644 lib/utils/leaderboard-href.ts create mode 100644 tests/leaderboard-href.test.ts diff --git a/AGENTS.md b/AGENTS.md index a27e590..3abfa8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,8 @@ bun run test # unit tests (node --test + tsx) — requires Node ≥22 ```text app/ layout.tsx # root layout (nav + footer) — defines root metadata + OG / Twitter cards - page.tsx # leaderboard + page.tsx # leaderboard (overall). The old `/?model=` 308s to /model/ + model/[slug]/page.tsx # one agent's leaderboard — indexed, in the sitemap; host / search / sort re-cuts stay noindex not-found.tsx # 404 — every unmatched path and every notFound() from the repo / score / package routes repo/[...slug]/page.tsx # repo detail with per-model suggestions (includes generateMetadata). Catch-all because a # GitLab owner can be a nested group: host first, repo name last, owner is the rest. @@ -58,7 +59,7 @@ app/ terms/page.tsx # terms of use (footer-linked) robots.ts # /robots.txt — wildcard + explicit AI-crawler allows. Filtered leaderboard views are deduped # with noindex+follow in page.tsx, not a Disallow — a blocked URL hides that directive - sitemap.ts # /sitemap.xml — static routes, language hubs, every repo (priority scaled by score), top packages + sitemap.ts # /sitemap.xml — static routes, agent boards, language hubs, every repo (priority scaled by score), top packages llms.txt/route.ts # /llms.txt — markdown manifest for LLM crawlers (Perplexity, Claude, ChatGPT search) api/repos/route.ts api/repo/[id]/route.ts @@ -91,7 +92,7 @@ components/ # Tailwind-styled React components CopySnippet.tsx, PackageLookupForm.tsx, BadgeAdoptedTag.tsx, BackToTop.tsx, GoogleAnalytics.tsx, LiveScoreForm.tsx, RecentScores.tsx, RecordScore.tsx, ReleaseAnnouncement.tsx, - LeaderboardTable.tsx + LeaderboardTable.tsx, LeaderboardBoard.tsx lib/ constants/ scoring.ts # score thresholds, visible limits @@ -105,6 +106,7 @@ lib/ repo-path.ts # repoPath / repoIdentity / ownerAndName — the one place a repo URL is built or parsed repo-summary.ts # per-repo rank, best/worst agent, key signals — the repo-specific text on repo pages language.ts # language slugs + grouping (casing varies in the data) + nearest-score peers for "Similar repos" + leaderboard-href.ts # leaderboardHref / modelPath / isModelId — the one place a leaderboard URL is built scoring/ signals/ # one file per signal + helpers + types + index weights.ts # per-model weight tables @@ -122,6 +124,7 @@ lib/ score.ts # liveScore(): commit resolve + metadata + materialize + scoreRepo package-lookup.ts # shared registry → repo lookup (used by /api/package + /package page) language-hubs.ts # language groups + hub stats (used by /language pages, sitemap) + leaderboard-view.ts # search params → the rows, page and filters one board shows (used by / and /model/) badge-adoption.ts # detectBadgeEmbed — reads the cloned README for an embedded AFC badge (dashboard metadata, NOT a scored signal; never vendored to siblings) db.ts # better-sqlite3 connection + queries (RANK_DB_PATH overrides the file) db-schema.ts # table definitions + migrations, applied by db.ts on open; repo_alias is created by the seed run instead @@ -150,6 +153,7 @@ tests/ repo-path.test.ts # repoPath / repoIdentity / ownerAndName repo-summary.test.ts # summarizeRepo / keySignals — ranks, ties, best/worst agent language.test.ts # languageSlug / groupByLanguage / nearestByScore + leaderboard-href.test.ts # leaderboardHref / isModelId path-resolution.test.ts # firstExisting / resolveRelative / resolveAllRelative — case-insensitive lookup seeds.test.ts # seedTargets guards, host-API finding classification, staleness, accept seed-reconcile.test.ts # reconcileSeeds against a temp DB — prune, rename in place, former-name lookup @@ -231,9 +235,9 @@ If either sibling isn't present locally, flag it; never silently skip the propag ## Adding a model 1. Add a `ModelProfile` to `MODELS` in `lib/scoring/weights.ts` — weights for every signal. -2. Appears automatically in the leaderboard model pills, methodology weight-profile panel, and repo-page suggestions. +2. Appears automatically in the leaderboard model pills, its own `/model/` board (sitemap and `llms.txt` included), the methodology weight-profile panel, and repo-page suggestions. 3. Update the hard-coded agent lists/counts that **don't** derive from `MODELS`: `APP_KEYWORDS` in `lib/version.ts`, `lib/skill-content.ts`, the "Which agents" FAQ in `app/methodology/page.tsx`, `app/skill/page.tsx`, `app/terms/page.tsx`, `app/opengraph-image.tsx`, the footer strip in `app/og/repo/[...slug]/route.tsx`, the Dataset description in `components/HomeJsonLd.tsx`, and `README.md`. Grep the current count as a word and a digit (e.g. `nine`, `9`) and the trailing `OpenHands, Pi` to find them all — `tasks/` and `lib/changelog.ts` are historical records and stay as-shipped, and `.claude/skills/agent-friendly/SKILL.md` is an install artifact pinned by `skills-lock.json` (it refreshes via `npx skills add`, never by hand). -4. Existing repos keep their old per-model rows until rescored. `PerModelScores` renders the missing model as "—" (not 0), but **everything that reads `model_score` via a JOIN degrades silently to empty** until the backfill lands: the leaderboard (`listLeaderboard`, i.e. `/?model=`) and `getLanguagePeers` (the Similar repos strip under `?model=`) both inner-join `model_score` and return **zero rows** for a model with no rows yet — no error, just an empty page. Reweighting an existing model has the same staleness problem in reverse: stored scores stay at the old weights until rescored. Either trigger `scheduled-rescore.yml` via `workflow_dispatch` at deploy time, or accept up to 6 hours of the empty/stale state until the cron runs. +4. Existing repos keep their old per-model rows until rescored. `PerModelScores` renders the missing model as "—" (not 0), but **everything that reads `model_score` via a JOIN degrades silently to empty** until the backfill lands: the leaderboard (`listLeaderboard`, i.e. `/model/`) and `getLanguagePeers` (the Similar repos strip under `?model=`) both inner-join `model_score` and return **zero rows** for a model with no rows yet — no error, just an empty page. Reweighting an existing model has the same staleness problem in reverse: stored scores stay at the old weights until rescored. Either trigger `scheduled-rescore.yml` via `workflow_dispatch` at deploy time, or accept up to 6 hours of the empty/stale state until the cron runs. 5. **Mirror to both siblings**: copy the weights change into `../agent-friendly-action/src/scoring/weights.ts` **and** `../agent-friendly-skill/src/scoring/weights.ts`, and log under "Unreleased" in each sibling's `CHANGELOG.md`. ## Adding a host @@ -279,7 +283,7 @@ Hooks docs: . - `/score/[host]/[owner]/[name]` turns a visitor-supplied slug into host API calls and a `/tmp` directory. Two guards carry that: the `SLUG` regex on the route (host slug alphabet — everything else is a probe, and each miss costs a tree-API call), and `safeAbsolute` in `lib/live-score/materialize.ts`, which is the only thing between an attacker-chosen tree path and the filesystem. Both are load-bearing; `tests/live-score.test.ts` covers the traversal cases. Fetched bytes are written to disk and read back by the scorer — never executed. - SQL: all queries parameterised. No interpolation. - `data/rank.db` is committed to a public repo, freed pages included. `bun run check-db-leaks` must pass before any commit of it; the rescore workflow runs it before pushing. -- HTML: React auto-escapes. The only `dangerouslySetInnerHTML` is server-built JSON-LD with `<` escaped to `\u003c` (`app/layout.tsx`, `app/about/page.tsx`, `app/action/page.tsx`, `app/skill/page.tsx`, `app/score/page.tsx`, `app/methodology/page.tsx`, `app/repo/[...slug]/page.tsx`, plus the `HomeJsonLd` component on the leaderboard and the `BreadcrumbJsonLd` component used by About / Changelog / Languages / Methodology / Packages (index and result) / Privacy / Roadmap / Terms); never feed user-controlled strings into it. +- HTML: React auto-escapes. The only `dangerouslySetInnerHTML` is server-built JSON-LD with `<` escaped to `\u003c` (`app/layout.tsx`, `app/about/page.tsx`, `app/action/page.tsx`, `app/skill/page.tsx`, `app/score/page.tsx`, `app/methodology/page.tsx`, `app/repo/[...slug]/page.tsx`, plus the `HomeJsonLd` component on the leaderboard and the `BreadcrumbJsonLd` component used by About / Agent boards / Changelog / Languages / Methodology / Packages (index and result) / Privacy / Roadmap / Terms); never feed user-controlled strings into it. - Local-path mode reads files; never writes outside `data/` and the clone workspace passed to `shallowClone`. - No auth yet. The only write a request can trigger is a package lookup caching its package → repo alias. When auth lands (`tasks/0.9.0/01-opt-out-claim-flow.md`), do it via OAuth and gate DB writes per user. diff --git a/README.md b/README.md index 4706662..ddf248d 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,10 @@ app/ Next.js App Router — pages + API + SEO skill/ agent-skill explainer + install command package/ registry → repo lookup (form + per-package state pages) language/ per-language hubs — /language, /language/ + model/ per-agent leaderboards — /model/ api/ /repos, /repo/[id], /score, /badge///, /package// robots.ts /robots.txt — allows "/", blocks "/api/" and "/score/" (unbounded URL space) - sitemap.ts /sitemap.xml — static routes, language hubs, every repo, top packages + sitemap.ts /sitemap.xml — static routes, agent boards, language hubs, every repo, top packages llms.txt/ markdown manifest for LLM crawlers globals.css Tailwind import + @theme tokens components/ React components (Tailwind-styled) diff --git a/app/api/badge/[...slug]/route.ts b/app/api/badge/[...slug]/route.ts index 972b1f7..095dabb 100644 --- a/app/api/badge/[...slug]/route.ts +++ b/app/api/badge/[...slug]/route.ts @@ -1,6 +1,7 @@ import { getModelScores, getRepoByHostOwnerName } from "@/lib/db"; -import { MODEL_BY_ID, type ModelId } from "@/lib/scoring/weights"; +import { MODEL_BY_ID } from "@/lib/scoring/weights"; import { badgeSvg } from "@/lib/utils/badge"; +import { isModelId } from "@/lib/utils/leaderboard-href"; import { repoIdentity } from "@/lib/utils/repo-path"; export const dynamic = "force-dynamic"; @@ -26,12 +27,11 @@ export async function GET(req: Request, ctx: { params: Promise<{ slug: string[] let label = "agent friendly"; let score = repo.overall_score; - if (modelParam && modelParam in MODEL_BY_ID) { - const m = modelParam as ModelId; - const ms = getModelScores(repo.id).find((s) => s.modelId === m); + if (isModelId(modelParam)) { + const ms = getModelScores(repo.id).find((s) => s.modelId === modelParam); score = ms ? ms.score : null; - label = `agent friendly · ${MODEL_BY_ID[m].label.toLowerCase()}`; + label = `agent friendly · ${MODEL_BY_ID[modelParam].label.toLowerCase()}`; } const value = score == null ? "—" : score.toFixed(1); diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index da75640..adb510d 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -1,6 +1,7 @@ import { REGISTRIES } from "@/lib/clients/registries"; import { getTopPackagesByRegistry } from "@/lib/db"; import { MODELS } from "@/lib/scoring/weights"; +import { modelPath } from "@/lib/utils/leaderboard-href"; import { ACTION_REPO_URL, APP_DESCRIPTION, @@ -42,7 +43,7 @@ The scoring engine evaluates sixteen static signals per repository — twelve cr ## Agents scored for -${MODELS.map((m) => `- ${m.label} — ${m.rationale}`).join("\n")} +${MODELS.map((m) => `- [${m.label}](${APP_URL}${modelPath(m.id)}): ${m.rationale}`).join("\n")} ## Key pages diff --git a/app/model/[slug]/page.tsx b/app/model/[slug]/page.tsx new file mode 100644 index 0000000..b69f685 --- /dev/null +++ b/app/model/[slug]/page.tsx @@ -0,0 +1,100 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { BreadcrumbJsonLd } from "@/components/BreadcrumbJsonLd"; +import { LeaderboardBoard } from "@/components/LeaderboardBoard"; +import { LEADERBOARD_PAGE_SIZE } from "@/lib/constants/scoring"; +import { getLeaderboardStats } from "@/lib/db"; +import { type LeaderboardSearchParams, resolveLeaderboardView } from "@/lib/leaderboard-view"; +import { MODEL_BY_ID } from "@/lib/scoring/weights"; +import { isModelId, leaderboardHref, modelPath } from "@/lib/utils/leaderboard-href"; +import { repoPath } from "@/lib/utils/repo-path"; +import { DEFAULT_OG_IMAGE, OG_DEFAULTS, TWITTER_DEFAULTS } from "@/lib/version"; + +type Props = { params: Promise<{ slug: string }>; searchParams: Promise }; + +export async function generateMetadata({ params, searchParams }: Props): Promise { + const { slug } = await params; + if (!isModelId(slug)) { + return {}; + } + + const view = resolveLeaderboardView(slug, await searchParams); + const { page, totalPages, filtered, filteredRows } = view; + const { label } = MODEL_BY_ID[slug]; + const canonical = leaderboardHref(view); + + const first = (page - 1) * LEADERBOARD_PAGE_SIZE + 1; + const last = Math.min(page * LEADERBOARD_PAGE_SIZE, filteredRows.length); + const top = filteredRows[0]; + + const title = + page > 1 ? `${label} leaderboard page ${page} of ${totalPages}` : `Most ${label}-ready open source repos`; + const description = + page > 1 + ? `Repos ranked ${first} to ${last} of ${filteredRows.length} by how easy they are for ${label} to work in.` + : `${filteredRows.length} public GitHub, GitLab and Bitbucket repos ranked by how easy they are for ${label} to work in, scored on what ${label} looks for.${ + top && !filtered ? ` ${top.owner}/${top.name} leads with ${(top.score ?? 0).toFixed(1)}.` : "" + }`; + + // Same indexing rules as the overall board in app/page.tsx. + return { + title, + description, + alternates: { canonical }, + ...(filtered ? { robots: { index: false, follow: true } } : {}), + twitter: { ...TWITTER_DEFAULTS, images: [DEFAULT_OG_IMAGE], title, description }, + openGraph: { ...OG_DEFAULTS, images: [DEFAULT_OG_IMAGE], title, description, url: canonical, type: "website" }, + }; +} + +export default async function ModelPage({ params, searchParams }: Props) { + const { slug } = await params; + if (!isModelId(slug)) { + notFound(); + } + + const view = resolveLeaderboardView(slug, await searchParams); + const { label } = MODEL_BY_ID[slug]; + const top = view.filtered ? undefined : view.filteredRows[0]; + + return ( + <> + + +
+

+ Which public repos are easiest for {label} to work in? +

+ +

+ Repos from GitHub, GitLab, and Bitbucket, ranked by their {label} score rather than the average across every + agent. + {top && ( + <> + {" "} + + {top.owner}/{top.name} + {" "} + leads with {(top.score ?? 0).toFixed(1)}. + + )} +

+ +

+ The{" "} + + methodology + {" "} + lists the weight {label} gives each check and the documentation it comes from. +

+
+ + + + ); +} diff --git a/app/page.tsx b/app/page.tsx index 290e77c..021b0ba 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,128 +1,33 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { cache } from "react"; +import { permanentRedirect } from "next/navigation"; import { HomeJsonLd } from "@/components/HomeJsonLd"; -import { HostSelect } from "@/components/HostSelect"; -import { LeaderboardTable } from "@/components/LeaderboardTable"; -import { ModelPills } from "@/components/ModelPills"; -import { Pagination } from "@/components/Pagination"; +import { LeaderboardBoard } from "@/components/LeaderboardBoard"; import { ReleaseAnnouncement } from "@/components/ReleaseAnnouncement"; -import { SearchBar } from "@/components/SearchBar"; -import { SortSelect } from "@/components/SortSelect"; import { CHANGELOG } from "@/lib/changelog"; -import { type Host, isHost } from "@/lib/constants/hosts"; -import { LEADERBOARD_PAGE_SIZE, MAX_SEARCH_LENGTH } from "@/lib/constants/scoring"; -import { DEFAULT_DIR, DEFAULT_SORT, isSortDir, isSortKey, type SortDir, type SortKey } from "@/lib/constants/sort"; -import { getLeaderboardStats, listLeaderboard, listLeaderboardOverall } from "@/lib/db"; -import { MODEL_BY_ID, MODELS, type ModelId } from "@/lib/scoring/weights"; -import type { LeaderboardRow } from "@/lib/types/db"; -import { relativeTime } from "@/lib/utils/format"; +import { LEADERBOARD_PAGE_SIZE } from "@/lib/constants/scoring"; +import { getLeaderboardStats, listLeaderboardOverall } from "@/lib/db"; +import { type LeaderboardSearchParams, resolveLeaderboardView } from "@/lib/leaderboard-view"; import { groupByLanguage, hubPath, isHub } from "@/lib/utils/language"; +import { isModelId, leaderboardHref } from "@/lib/utils/leaderboard-href"; import { APP_NAME, APP_VERSION, OG_DEFAULTS, TWITTER_DEFAULTS } from "@/lib/version"; const HOME_TITLE = "Agent Friendly Code — is your codebase ready for AI agents?"; const HOME_DESCRIPTION = "Public GitHub, GitLab and Bitbucket repos ranked by how ready they are for AI coding agents, with a score for each agent. Or score your own codebase."; -type SearchParams = { - q?: string; - dir?: string; - host?: string; - page?: string; - sort?: string; - model?: string; -}; - -type HrefParts = { - q?: string; - dir: SortDir; - page: number; - sort: SortKey; - host: Host | "all"; - model: ModelId | "overall"; -}; - -function buildHref(parts: HrefParts): string { - const p = new URLSearchParams(); - - // Defaults stay out of the URL so the unfiltered board has one address — - // `/`, never a second copy of it at `/?model=overall`. - if (parts.model !== "overall") { - p.set("model", parts.model); - } - if (parts.host !== "all") { - p.set("host", parts.host); - } - if (parts.q) { - p.set("q", parts.q); - } - if (parts.sort !== DEFAULT_SORT) { - p.set("sort", parts.sort); - } - if (parts.dir !== DEFAULT_DIR) { - p.set("dir", parts.dir); - } - if (parts.page > 1) { - p.set("page", String(parts.page)); - } - - const s = p.toString(); - return s ? `/?${s}` : "/"; -} - -function matchesQuery(row: LeaderboardRow, q: string): boolean { - return `${row.owner}/${row.name}`.toLowerCase().includes(q.trim().toLowerCase()); -} - -type View = { - q: string; - page: number; - totalPages: number; - dir: SortDir; - sort: SortKey; - host: Host | "all"; - selected: ModelId | "overall"; - filteredRows: LeaderboardRow[]; -}; - -// Shared by generateMetadata and the page body, which run in one request -// scope — without this the whole board is queried and filtered twice. -const resolveView = cache(async (sp: SearchParams): Promise => { - const selected: ModelId | "overall" = sp.model && sp.model in MODEL_BY_ID ? (sp.model as ModelId) : "overall"; - - const q = (sp.q ?? "").slice(0, MAX_SEARCH_LENGTH); - const host: Host | "all" = isHost(sp.host) ? sp.host : "all"; - const dir: SortDir = isSortDir(sp.dir) ? sp.dir : DEFAULT_DIR; - const sort: SortKey = isSortKey(sp.sort) ? sp.sort : DEFAULT_SORT; - - const baseRows = listLeaderboard({ - dir, - sort, - model: selected, - host: host === "all" ? undefined : host, - }); - - const filteredRows = q ? baseRows.filter((r) => matchesQuery(r, q)) : baseRows; - const totalPages = Math.max(1, Math.ceil(filteredRows.length / LEADERBOARD_PAGE_SIZE)); - - // Out-of-range clamps rather than 404s, so a stale or hand-typed page number - // still lands on rows; the canonical below folds it onto the page it shows. - const parsedPage = Number(sp.page); - const page = Number.isFinite(parsedPage) ? Math.min(totalPages, Math.max(1, Math.floor(parsedPage))) : 1; - - return { q, page, totalPages, dir, sort, host, selected, filteredRows }; -}); +type SearchParams = LeaderboardSearchParams & { model?: string }; export async function generateMetadata({ searchParams }: { searchParams: Promise }): Promise { - const { q, page, totalPages, dir, sort, host, selected, filteredRows } = await resolveView(await searchParams); - const canonical = buildHref({ model: selected, host, q, sort, dir, page }); + const view = resolveLeaderboardView("overall", await searchParams); + const { page, totalPages, filtered, filteredRows } = view; // Every view points at itself. Canonicalising page 2+ back to `/` declared // the deeper pages duplicates, which drained the repos only linked from them. // A filtered view is a re-cut of rows that already have a home, so it takes // `noindex, follow`: crawlable and link-passing, just not indexed. A Disallow // would instead hide that directive from the crawler that has to read it. - const filtered = selected !== "overall" || host !== "all" || q !== "" || sort !== DEFAULT_SORT || dir !== DEFAULT_DIR; + const canonical = leaderboardHref(view); // Deeper pages are indexed in their own right, so each needs its own title // and description or they read as copies of page 1. @@ -145,21 +50,15 @@ export async function generateMetadata({ searchParams }: { searchParams: Promise } export default async function Page({ searchParams }: { searchParams: Promise }) { - const { q, page, totalPages, dir, sort, host, selected, filteredRows } = await resolveView(await searchParams); - - const stats = getLeaderboardStats(); - const startIdx = (page - 1) * LEADERBOARD_PAGE_SIZE; - const rows = filteredRows.slice(startIdx, startIdx + LEADERBOARD_PAGE_SIZE); + const sp = await searchParams; - const activeLabel = - selected === "overall" - ? "Overall (average of all agents)" - : (MODELS.find((m) => m.id === selected)?.label ?? selected); + // An agent's board used to be `/?model=`; those links are still out there. + if (isModelId(sp.model)) { + permanentRedirect(leaderboardHref(resolveLeaderboardView(sp.model, sp))); + } - const rationale = - selected === "overall" - ? "The average of every agent's score." - : (MODELS.find((m) => m.id === selected)?.rationale ?? ""); + const view = resolveLeaderboardView("overall", sp); + const stats = getLeaderboardStats(); const allOverall = listLeaderboardOverall(); const hubs = groupByLanguage(allOverall).filter(isHub); @@ -213,57 +112,7 @@ export default async function Page({ searchParams }: { searchParams: Promise - buildHref({ model: id, host, q, sort, dir, page: 1 })} - /> -
- {activeLabel} — {rationale} -
- -
- - -
- - -
-
- - - -
-

- Tracking {stats.count}{" "} - {stats.count === 1 ? "repo" : "repos"} - {stats.lastScoredAt != null && ( - <> - {" · "}last updated{" "} - - - )} -

- - buildHref({ model: selected, host, q, sort, dir, page: p })} - /> -
+ {/* Announced only once the release it describes is the one deployed — otherwise a version bump ahead of the changelog entry (or behind it) diff --git a/app/repo/[...slug]/page.tsx b/app/repo/[...slug]/page.tsx index 48e0c08..bff5ad1 100644 --- a/app/repo/[...slug]/page.tsx +++ b/app/repo/[...slug]/page.tsx @@ -26,6 +26,7 @@ import { topImprovements } from "@/lib/scoring/scorer"; import { MODEL_BY_ID, MODELS, type ModelId } from "@/lib/scoring/weights"; import type { RepoRow } from "@/lib/types/db"; import { groupByLanguage, hubPath, isHub, languageSlug, nearestByScore } from "@/lib/utils/language"; +import { isModelId } from "@/lib/utils/leaderboard-href"; import { repoIdentity, repoPath } from "@/lib/utils/repo-path"; import { summarizeRepo, summaryDescription } from "@/lib/utils/repo-summary"; import { ACTION_USES, APP_KEYWORDS, APP_URL, OG_DEFAULTS, OG_IMAGE_SIZE, TWITTER_DEFAULTS } from "@/lib/version"; @@ -112,7 +113,7 @@ export default async function Page({ permanentRedirect(model ? `${path}?model=${encodeURIComponent(model)}` : path); } - const selected: ModelId = model && model in MODEL_BY_ID ? (model as ModelId) : "claude-code"; + const selected: ModelId = isModelId(model) ? model : "claude-code"; const signals = getSignalResults(id); const modelScores = getModelScores(id); diff --git a/app/score/[host]/[owner]/[name]/page.tsx b/app/score/[host]/[owner]/[name]/page.tsx index 501b18b..bd79a85 100644 --- a/app/score/[host]/[owner]/[name]/page.tsx +++ b/app/score/[host]/[owner]/[name]/page.tsx @@ -22,6 +22,7 @@ import { MODEL_BY_ID, type ModelId } from "@/lib/scoring/weights"; import type { RepoRow } from "@/lib/types/db"; import { hostLabel } from "@/lib/utils/format"; import { nearestByScore } from "@/lib/utils/language"; +import { isModelId } from "@/lib/utils/leaderboard-href"; import { repoPath } from "@/lib/utils/repo-path"; import { DEFAULT_OG_IMAGE, OG_DEFAULTS, TWITTER_DEFAULTS } from "@/lib/version"; @@ -143,7 +144,7 @@ export default async function LiveScorePage({ return ; } - const selected: ModelId = model && model in MODEL_BY_ID ? (model as ModelId) : "claude-code"; + const selected: ModelId = isModelId(model) ? model : "claude-code"; const suggestions = topImprovements(selected, score.signals); const strengths = score.signals.filter((s) => s.pass >= 1).slice(0, STRENGTHS_GAPS_VISIBLE_LIMIT); const gaps = score.signals.filter((s) => s.pass === 0).slice(0, STRENGTHS_GAPS_VISIBLE_LIMIT); diff --git a/app/sitemap.ts b/app/sitemap.ts index d499efb..faafed6 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -3,7 +3,9 @@ import type { MetadataRoute } from "next"; import { REGISTRIES } from "@/lib/clients/registries"; import { getLeaderboardStats, getTopPackagesByRegistry, listLeaderboardOverall } from "@/lib/db"; import { getLanguageGroups } from "@/lib/language-hubs"; +import { MODELS } from "@/lib/scoring/weights"; import { hubPath, isHub } from "@/lib/utils/language"; +import { modelPath } from "@/lib/utils/leaderboard-href"; import { repoPath } from "@/lib/utils/repo-path"; import { APP_URL } from "@/lib/version"; @@ -110,6 +112,13 @@ export default function sitemap(): MetadataRoute.Sitemap { }; }); + const modelRoutes: MetadataRoute.Sitemap = MODELS.map((m) => ({ + priority: 0.8, + changeFrequency: "daily", + lastModified: contentChanged, + url: `${APP_URL}${modelPath(m.id)}`, + })); + const packageRoutes: MetadataRoute.Sitemap = REGISTRIES.flatMap((registry) => getTopPackagesByRegistry(registry, SITEMAP_PACKAGE_LIMIT_PER_REGISTRY).map((p) => ({ lastModified: p.contentChangedAt != null ? new Date(p.contentChangedAt * 1000) : undefined, @@ -119,5 +128,5 @@ export default function sitemap(): MetadataRoute.Sitemap { })), ); - return [...staticRoutes, ...hubRoutes, ...repoRoutes, ...packageRoutes]; + return [...staticRoutes, ...modelRoutes, ...hubRoutes, ...repoRoutes, ...packageRoutes]; } diff --git a/components/HostSelect.tsx b/components/HostSelect.tsx index 33297ab..63da514 100644 --- a/components/HostSelect.tsx +++ b/components/HostSelect.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useRef } from "react"; import { HOST_LABELS, HOSTS, type Host } from "@/lib/constants/hosts"; @@ -13,6 +13,7 @@ const OPTIONS: SelectMenuOption[] = [ export function HostSelect({ value }: { value: Host | "all" }) { const router = useRouter(); + const pathname = usePathname(); const params = useSearchParams(); const paramsRef = useRef(params); @@ -30,7 +31,7 @@ export function HostSelect({ value }: { value: Host | "all" }) { next.delete("page"); const qs = next.toString(); - router.replace(qs ? `/?${qs}` : "/", { scroll: false }); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); }; return ; diff --git a/components/LeaderboardBoard.tsx b/components/LeaderboardBoard.tsx new file mode 100644 index 0000000..2bec4cd --- /dev/null +++ b/components/LeaderboardBoard.tsx @@ -0,0 +1,74 @@ +import { LEADERBOARD_PAGE_SIZE } from "@/lib/constants/scoring"; +import type { LeaderboardView } from "@/lib/leaderboard-view"; +import { MODEL_BY_ID } from "@/lib/scoring/weights"; +import type { LeaderboardStats } from "@/lib/types/db"; +import { relativeTime } from "@/lib/utils/format"; +import { leaderboardHref } from "@/lib/utils/leaderboard-href"; +import { HostSelect } from "./HostSelect"; +import { LeaderboardTable } from "./LeaderboardTable"; +import { ModelPills } from "./ModelPills"; +import { Pagination } from "./Pagination"; +import { SearchBar } from "./SearchBar"; +import { SortSelect } from "./SortSelect"; + +export function LeaderboardBoard({ view, stats }: { view: LeaderboardView; stats: LeaderboardStats }) { + const { q, page, totalPages, dir, sort, host, model, filteredRows } = view; + + const startIdx = (page - 1) * LEADERBOARD_PAGE_SIZE; + const rows = filteredRows.slice(startIdx, startIdx + LEADERBOARD_PAGE_SIZE); + + const activeLabel = model === "overall" ? "Overall (average of all agents)" : MODEL_BY_ID[model].label; + const rationale = model === "overall" ? "The average of every agent's score." : MODEL_BY_ID[model].rationale; + + return ( + <> + leaderboardHref({ ...view, model: id, page: 1 })} /> +
+ {activeLabel} — {rationale} +
+ +
+ + +
+ + +
+
+ + + +
+

+ Tracking {stats.count}{" "} + {stats.count === 1 ? "repo" : "repos"} + {stats.lastScoredAt != null && ( + <> + {" · "}last updated{" "} + + + )} +

+ + leaderboardHref({ ...view, page: p })} + /> +
+ + ); +} diff --git a/components/SearchBar.tsx b/components/SearchBar.tsx index 43a59fe..0efb25e 100644 --- a/components/SearchBar.tsx +++ b/components/SearchBar.tsx @@ -1,7 +1,7 @@ "use client"; import { MagnifyingGlass } from "@phosphor-icons/react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { MAX_SEARCH_LENGTH } from "@/lib/constants/scoring"; @@ -13,8 +13,9 @@ const DEBOUNCE_MS = 350; export function SearchBar({ placeholder = "Search repos by owner/name" }: Props) { const router = useRouter(); + const pathname = usePathname(); const params = useSearchParams(); - // Capped like the server reads it (app/page.tsx): a hand-typed long `q` + // Capped like the server reads it (lib/leaderboard-view.ts): a hand-typed long `q` // would otherwise sit in the box while the board filtered on the slice. // The push below then rewrites the URL down to it. const currentQ = (params.get("q") ?? "").slice(0, MAX_SEARCH_LENGTH); @@ -45,11 +46,12 @@ export function SearchBar({ placeholder = "Search repos by owner/name" }: Props) } next.delete("page"); - router.replace(`/?${next.toString()}`, { scroll: false }); + const qs = next.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); }, DEBOUNCE_MS); return () => clearTimeout(t); - }, [value, router]); + }, [value, router, pathname]); return ( diff --git a/components/SortSelect.tsx b/components/SortSelect.tsx index a505008..c5239f3 100644 --- a/components/SortSelect.tsx +++ b/components/SortSelect.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useRef } from "react"; import { DEFAULT_DIR, DEFAULT_SORT, type SortDir, type SortKey } from "@/lib/constants/sort"; @@ -33,6 +33,7 @@ const OPTIONS: SelectMenuOption[] = [ export function SortSelect({ sort, dir }: Props) { const router = useRouter(); + const pathname = usePathname(); const params = useSearchParams(); const paramsRef = useRef(params); @@ -58,7 +59,7 @@ export function SortSelect({ sort, dir }: Props) { next.delete("page"); const qs = next.toString(); - router.replace(qs ? `/?${qs}` : "/", { scroll: false }); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); }; return ; diff --git a/lib/leaderboard-view.ts b/lib/leaderboard-view.ts new file mode 100644 index 0000000..5414e3c --- /dev/null +++ b/lib/leaderboard-view.ts @@ -0,0 +1,63 @@ +import { cache } from "react"; + +import { type Host, isHost } from "@/lib/constants/hosts"; +import { LEADERBOARD_PAGE_SIZE, MAX_SEARCH_LENGTH } from "@/lib/constants/scoring"; +import { DEFAULT_DIR, DEFAULT_SORT, isSortDir, isSortKey, type SortDir, type SortKey } from "@/lib/constants/sort"; +import { listLeaderboard } from "@/lib/db"; +import type { ModelId } from "@/lib/scoring/weights"; +import type { LeaderboardRow } from "@/lib/types/db"; + +export type LeaderboardSearchParams = { + q?: string; + dir?: string; + host?: string; + page?: string; + sort?: string; +}; + +export type LeaderboardView = { + q: string; + page: number; + totalPages: number; + dir: SortDir; + sort: SortKey; + host: Host | "all"; + model: ModelId | "overall"; + /** A re-cut of the board (host, search, sort) rather than the board itself. */ + filtered: boolean; + filteredRows: LeaderboardRow[]; +}; + +function matchesQuery(row: LeaderboardRow, q: string): boolean { + return `${row.owner}/${row.name}`.toLowerCase().includes(q.trim().toLowerCase()); +} + +// Shared by generateMetadata and the page body, which run in one request +// scope — without this the whole board is queried and filtered twice. +export const resolveLeaderboardView = cache( + (model: ModelId | "overall", sp: LeaderboardSearchParams): LeaderboardView => { + const q = (sp.q ?? "").slice(0, MAX_SEARCH_LENGTH); + const host: Host | "all" = isHost(sp.host) ? sp.host : "all"; + const dir: SortDir = isSortDir(sp.dir) ? sp.dir : DEFAULT_DIR; + const sort: SortKey = isSortKey(sp.sort) ? sp.sort : DEFAULT_SORT; + + const baseRows = listLeaderboard({ + dir, + sort, + model, + host: host === "all" ? undefined : host, + }); + + const filteredRows = q ? baseRows.filter((r) => matchesQuery(r, q)) : baseRows; + const totalPages = Math.max(1, Math.ceil(filteredRows.length / LEADERBOARD_PAGE_SIZE)); + + // Out-of-range clamps rather than 404s, so a stale or hand-typed page number + // still lands on rows; the canonical folds it onto the page it shows. + const parsedPage = Number(sp.page); + const page = Number.isFinite(parsedPage) ? Math.min(totalPages, Math.max(1, Math.floor(parsedPage))) : 1; + + const filtered = host !== "all" || q !== "" || sort !== DEFAULT_SORT || dir !== DEFAULT_DIR; + + return { q, page, totalPages, dir, sort, host, model, filtered, filteredRows }; + }, +); diff --git a/lib/utils/leaderboard-href.ts b/lib/utils/leaderboard-href.ts new file mode 100644 index 0000000..f81cff6 --- /dev/null +++ b/lib/utils/leaderboard-href.ts @@ -0,0 +1,48 @@ +import type { Host } from "@/lib/constants/hosts"; +import { DEFAULT_DIR, DEFAULT_SORT, type SortDir, type SortKey } from "@/lib/constants/sort"; +import { MODEL_BY_ID, type ModelId } from "@/lib/scoring/weights"; + +export type LeaderboardHrefParts = { + q?: string; + dir: SortDir; + page: number; + sort: SortKey; + host: Host | "all"; + model: ModelId | "overall"; +}; + +// `in` would also accept `constructor` and the rest of Object.prototype. +export function isModelId(value: string | null | undefined): value is ModelId { + return value != null && Object.hasOwn(MODEL_BY_ID, value); +} + +export function modelPath(id: ModelId): string { + return `/model/${id}`; +} + +export function leaderboardHref(parts: LeaderboardHrefParts): string { + const p = new URLSearchParams(); + + // Defaults stay out of the URL so each board has one address. + if (parts.host !== "all") { + p.set("host", parts.host); + } + if (parts.q) { + p.set("q", parts.q); + } + if (parts.sort !== DEFAULT_SORT) { + p.set("sort", parts.sort); + } + if (parts.dir !== DEFAULT_DIR) { + p.set("dir", parts.dir); + } + if (parts.page > 1) { + p.set("page", String(parts.page)); + } + + // The agent is a path segment because its board ranks differently and is + // indexed as its own page; the params above only re-cut a board. + const base = parts.model === "overall" ? "/" : modelPath(parts.model); + const s = p.toString(); + return s ? `${base}?${s}` : base; +} diff --git a/tasks/0.9.0/README.md b/tasks/0.9.0/README.md index df90182..b91105d 100644 --- a/tasks/0.9.0/README.md +++ b/tasks/0.9.0/README.md @@ -8,3 +8,7 @@ Two heavier items that depend on real surface-area additions: an OAuth flow with - [01-opt-out-claim-flow.md](./01-opt-out-claim-flow.md) — OAuth so maintainers can claim or opt out of their listing. First touchpoint that writes to the DB on behalf of a user. - [02-package-registry-overlay.md](./02-package-registry-overlay.md) — at-scale package overlay: per-registry leaderboards on the dashboard + a browser userscript that renders the badge inline on npmjs.com / PyPI / crates.io. Builds on the v0.3.0 lookup endpoint. + +## Already shipped + +- **Agent leaderboards** — `/model/` ranks every repo for one agent, indexed and in the sitemap; `/?model=` redirects there. Owed a changelog line when this version is cut. diff --git a/tests/leaderboard-href.test.ts b/tests/leaderboard-href.test.ts new file mode 100644 index 0000000..725dde0 --- /dev/null +++ b/tests/leaderboard-href.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { DEFAULT_DIR, DEFAULT_SORT } from "../lib/constants/sort"; +import { isModelId, type LeaderboardHrefParts, leaderboardHref } from "../lib/utils/leaderboard-href"; + +const DEFAULTS: LeaderboardHrefParts = { model: "overall", host: "all", sort: DEFAULT_SORT, dir: DEFAULT_DIR, page: 1 }; + +describe("leaderboardHref", () => { + it("gives each unfiltered board one address", () => { + assert.equal(leaderboardHref(DEFAULTS), "/"); + assert.equal(leaderboardHref({ ...DEFAULTS, model: "claude-code" }), "/model/claude-code"); + }); + + it("keeps the agent in the path and the re-cuts in the query", () => { + assert.equal(leaderboardHref({ ...DEFAULTS, page: 2 }), "/?page=2"); + assert.equal( + leaderboardHref({ ...DEFAULTS, model: "cursor", host: "github", q: "next", page: 3 }), + "/model/cursor?host=github&q=next&page=3", + ); + }); +}); + +describe("isModelId", () => { + it("rejects inherited object keys", () => { + assert.equal(isModelId("claude-code"), true); + assert.equal(isModelId("constructor"), false); + assert.equal(isModelId(undefined), false); + }); +});