diff --git a/.changeset/fresh-extension-stars.md b/.changeset/fresh-extension-stars.md
new file mode 100644
index 000000000..fa599fbe6
--- /dev/null
+++ b/.changeset/fresh-extension-stars.md
@@ -0,0 +1,5 @@
+---
+"hunkdiff": patch
+---
+
+Keep community extension stars and update dates fresh between hunk.dev deployments.
diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml
index 139ee2836..7d2ea2ba7 100644
--- a/.github/workflows/website.yml
+++ b/.github/workflows/website.yml
@@ -33,7 +33,7 @@ jobs:
run: bun install --cwd website --frozen-lockfile
- name: Test website tooling
- run: bun test scripts/generate-docs.test.ts scripts/check-website-links.test.ts scripts/check-extension-catalog.test.ts
+ run: bun test api/extension-activity.test.ts scripts/generate-docs.test.ts scripts/check-website-links.test.ts scripts/check-extension-catalog.test.ts
- name: Check generated reference docs
run: bun run check:docs
diff --git a/api/extension-activity.test.ts b/api/extension-activity.test.ts
new file mode 100644
index 000000000..781e1610f
--- /dev/null
+++ b/api/extension-activity.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, test } from "bun:test";
+import { handleExtensionActivityRequest } from "./extension-activity";
+
+/** Build a fetch-compatible stub around one concise test callback. */
+function createTestFetch(
+ callback: (input: string | URL | Request, init?: RequestInit) => Response | Promise,
+) {
+ return callback as typeof fetch;
+}
+
+describe("extension activity endpoint", () => {
+ test("returns compact GitHub activity with shared CDN caching", async () => {
+ let requestedUrl = "";
+ const fetchUpstream = createTestFetch((input) => {
+ requestedUrl = String(input);
+ return Response.json({
+ items: [
+ {
+ full_name: "Elucid/Hunk-Less-Search",
+ stargazers_count: 12,
+ pushed_at: "2026-08-20T03:25:47Z",
+ created_at: "2026-08-16T22:57:51Z",
+ description: "This upstream field must not be forwarded",
+ },
+ ],
+ });
+ });
+
+ const response = await handleExtensionActivityRequest(
+ new Request("https://hunk.dev/api/extension-activity"),
+ fetchUpstream,
+ );
+
+ expect(requestedUrl).toContain("api.github.com/search/repositories");
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Cache-Control")).toBe(
+ "public, max-age=60, s-maxage=3600, stale-while-revalidate=86400",
+ );
+ expect(await response.json()).toMatchObject({
+ fetchedAt: expect.any(String),
+ repositories: [
+ {
+ repo: "elucid/hunk-less-search",
+ stars: 12,
+ pushedAt: "2026-08-20T03:25:47Z",
+ createdAt: "2026-08-16T22:57:51Z",
+ },
+ ],
+ });
+ });
+
+ test("does not cache upstream failures", async () => {
+ const response = await handleExtensionActivityRequest(
+ new Request("https://hunk.dev/api/extension-activity"),
+ createTestFetch(() => new Response("rate limited", { status: 429 })),
+ );
+
+ expect(response.status).toBe(502);
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
+ expect(await response.json()).toEqual({
+ error: "Extension activity is temporarily unavailable",
+ });
+ });
+
+ test("rejects non-GET requests without calling GitHub", async () => {
+ let called = false;
+ const response = await handleExtensionActivityRequest(
+ new Request("https://hunk.dev/api/extension-activity", { method: "POST" }),
+ createTestFetch(() => {
+ called = true;
+ return new Response();
+ }),
+ );
+
+ expect(called).toBe(false);
+ expect(response.status).toBe(405);
+ expect(response.headers.get("Allow")).toBe("GET");
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
+ });
+});
diff --git a/api/extension-activity.ts b/api/extension-activity.ts
new file mode 100644
index 000000000..e45f43ac0
--- /dev/null
+++ b/api/extension-activity.ts
@@ -0,0 +1,72 @@
+import {
+ createExtensionActivityPayload,
+ githubTopicActivityUrl,
+ indexActivityByRepo,
+} from "../website/src/data/extensionActivity";
+
+const CACHE_CONTROL = "public, max-age=60, s-maxage=3600, stale-while-revalidate=86400";
+const NO_STORE = "no-store";
+
+/** Build the authenticated GitHub headers available only to the server. */
+function githubHeaders() {
+ const token = process.env.GITHUB_TOKEN;
+ return {
+ Accept: "application/vnd.github+json",
+ "User-Agent": "hunk.dev-extension-directory",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ };
+}
+
+/** Return one JSON response with an explicit browser and CDN cache policy. */
+function jsonResponse(payload: unknown, status: number, cacheControl: string) {
+ return Response.json(payload, {
+ status,
+ headers: { "Cache-Control": cacheControl },
+ });
+}
+
+/** Serve compact extension activity through Vercel's shared CDN cache. */
+export async function handleExtensionActivityRequest(
+ request: Request,
+ fetchUpstream: typeof fetch = fetch,
+) {
+ if (request.method !== "GET") {
+ const response = jsonResponse({ error: "Method not allowed" }, 405, NO_STORE);
+ response.headers.set("Allow", "GET");
+ return response;
+ }
+
+ try {
+ const upstream = await fetchUpstream(githubTopicActivityUrl(), {
+ headers: githubHeaders(),
+ signal: AbortSignal.timeout(8000),
+ });
+ if (!upstream.ok) {
+ return jsonResponse(
+ { error: "Extension activity is temporarily unavailable" },
+ 502,
+ NO_STORE,
+ );
+ }
+
+ const activity = indexActivityByRepo(await upstream.json());
+ if (!activity.size) {
+ return jsonResponse(
+ { error: "Extension activity is temporarily unavailable" },
+ 502,
+ NO_STORE,
+ );
+ }
+
+ return jsonResponse(createExtensionActivityPayload(activity), 200, CACHE_CONTROL);
+ } catch {
+ return jsonResponse({ error: "Extension activity is temporarily unavailable" }, 502, NO_STORE);
+ }
+}
+
+export default {
+ /** Adapt the web-standard handler to Vercel's fetch function contract. */
+ fetch(request: Request) {
+ return handleExtensionActivityRequest(request);
+ },
+};
diff --git a/scripts/check-extension-catalog.test.ts b/scripts/check-extension-catalog.test.ts
index 240ed778f..e4d6b2802 100644
--- a/scripts/check-extension-catalog.test.ts
+++ b/scripts/check-extension-catalog.test.ts
@@ -5,8 +5,10 @@ import {
EXTENSION_CATALOG,
avatarUrl,
categoryFacets,
+ createExtensionActivityPayload,
formatUpdated,
indexActivityByRepo,
+ indexPublishedActivity,
installCommand,
ownerOf,
repositoryUrl,
@@ -110,6 +112,39 @@ describe("extension directory catalog", () => {
}
});
+ test("round-trips compact browser-safe activity", () => {
+ const payload = createExtensionActivityPayload(
+ new Map([
+ [
+ "elucid/hunk-less-search",
+ {
+ stars: 12,
+ pushedAt: "2026-08-20T03:25:47Z",
+ createdAt: "2026-08-16T22:57:51Z",
+ },
+ ],
+ ]),
+ new Date("2026-08-20T12:00:00Z"),
+ );
+
+ expect(payload).toEqual({
+ fetchedAt: "2026-08-20T12:00:00.000Z",
+ repositories: [
+ {
+ repo: "elucid/hunk-less-search",
+ stars: 12,
+ pushedAt: "2026-08-20T03:25:47Z",
+ createdAt: "2026-08-16T22:57:51Z",
+ },
+ ],
+ });
+ expect(indexPublishedActivity(payload).get("elucid/hunk-less-search")).toEqual({
+ stars: 12,
+ pushedAt: "2026-08-20T03:25:47Z",
+ createdAt: "2026-08-16T22:57:51Z",
+ });
+ });
+
test("neutralizes markup when serializing JSON-LD", () => {
const body = toJsonLdScriptBody({ name: "
" });
diff --git a/tsconfig.json b/tsconfig.json
index d10526797..849027272 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -29,6 +29,7 @@
"noUnusedParameters": false
},
"include": [
+ "api/**/*.ts",
"src/**/*.ts",
"src/**/*.tsx",
"scripts/**/*.ts",
diff --git a/vercel.json b/vercel.json
index b1a47230c..4b41e1452 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,7 +1,7 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "astro",
- "ignoreCommand": "git diff --quiet HEAD^ HEAD -- ./website ./vercel.json ./install.sh ./scripts/stage-install-script.ts ./scripts/generate-docs.ts ./scripts/generate-changelog.ts ./CHANGELOG.md",
+ "ignoreCommand": "git diff --quiet HEAD^ HEAD -- ./api ./website ./vercel.json ./install.sh ./scripts/stage-install-script.ts ./scripts/generate-docs.ts ./scripts/generate-changelog.ts ./CHANGELOG.md",
"installCommand": "SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install --frozen-lockfile && bun install --cwd website --frozen-lockfile",
"buildCommand": "bun run website:build",
"outputDirectory": "website/dist",
diff --git a/website/src/content/docs/docs/help/deployment.md b/website/src/content/docs/docs/help/deployment.md
index adc62675b..b84b520ec 100644
--- a/website/src/content/docs/docs/help/deployment.md
+++ b/website/src/content/docs/docs/help/deployment.md
@@ -3,14 +3,16 @@ title: Deployment integration
description: Publish the Hunk landing page and documentation together from one Astro build.
---
-The `website/` Astro project owns the complete `hunk.dev` site:
+The `website/` Astro project owns the complete static `hunk.dev` site:
- `/` is the marketing landing page.
- `/docs/` and `/docs/*` are the Starlight documentation.
- `/pagefind/` contains the documentation search index.
- `/docs/hunk-review-skill.md` publishes the generated agent skill.
-One static build keeps navigation, metadata, and deployment atomic. Do not copy the docs into the former `hunk-web` repository or operate a second docs origin.
+The repository-root `api/extension-activity.ts` function supplies fresh GitHub activity to the extension directory through Vercel's shared CDN cache. The static page keeps its build-time metadata when that optional refresh is unavailable.
+
+One deployment keeps navigation, metadata, and the cached endpoint atomic. Do not copy the docs into the former `hunk-web` repository or operate a second docs origin.
## Build the immutable artifact
@@ -24,7 +26,7 @@ bun run website:build
bun run website:links
```
-Archive `website/dist/` as one deployable artifact. `bun run website:build` checks that generated references and the public agent skill still match their authoritative runtime sources before Astro builds the site.
+Archive `website/dist/` as the static deployable artifact. `bun run website:build` checks that generated references and the public agent skill still match their authoritative runtime sources before Astro builds the site. Vercel additionally packages `api/extension-activity.ts`; deployments that serve only the static artifact retain the extension directory's build-time metadata.
## Deploy with Vercel
@@ -34,7 +36,7 @@ The repository-level `vercel.json` defines the install command, build command, A
- **Root directory:** repository root
- **Production branch:** `main`
- **Domain:** `hunk.dev` and `www.hunk.dev`
-- **Optional environment variable:** `GITHUB_TOKEN` for authenticated build-time star counts
+- **Optional environment variable:** `GITHUB_TOKEN` for authenticated build-time and cached extension star counts
Vercel should deploy pushes to `main` after the website and repository checks pass. Pull requests can use preview deployments from the same project.
@@ -55,6 +57,7 @@ curl --fail --location https://hunk.dev/docs/
curl --fail https://hunk.dev/sitemap-index.xml
curl --fail https://hunk.dev/pagefind/pagefind.js
curl --fail https://hunk.dev/docs/hunk-review-skill.md
+curl --fail https://hunk.dev/api/extension-activity
curl --fail https://hunk.dev/og.png
```
diff --git a/website/src/data/extensionActivity.ts b/website/src/data/extensionActivity.ts
new file mode 100644
index 000000000..388465dcd
--- /dev/null
+++ b/website/src/data/extensionActivity.ts
@@ -0,0 +1,105 @@
+/** GitHub topic an author adds to be listed. */
+export const HUNK_EXTENSION_TOPIC = "hunk-extension";
+
+/** Repository facts fetched from GitHub, absent when GitHub omits them. */
+export interface ExtensionActivity {
+ stars?: number;
+ pushedAt?: string;
+ createdAt?: string;
+}
+
+/** One compact repository record returned by Hunk's cached activity endpoint. */
+export interface PublishedExtensionActivity extends ExtensionActivity {
+ repo: string;
+}
+
+/** Browser-safe response from Hunk's cached extension activity endpoint. */
+export interface ExtensionActivityPayload {
+ fetchedAt: string;
+ repositories: PublishedExtensionActivity[];
+}
+
+/** Phrase one ISO timestamp as the coarse recency a directory card wants. */
+export function formatUpdated(pushedAt: string, now = new Date()) {
+ const days = Math.floor((now.getTime() - new Date(pushedAt).getTime()) / 86_400_000);
+ if (!Number.isFinite(days) || days < 0) return undefined;
+ if (days === 0) return "today";
+ if (days === 1) return "yesterday";
+ if (days < 30) return `${days} days ago`;
+ const months = Math.floor(days / 30);
+ if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`;
+ const years = Math.floor(days / 365);
+ return `${years} year${years === 1 ? "" : "s"} ago`;
+}
+
+/** Read one repository's volatile facts out of a GitHub API repository object. */
+export function readActivity(value: unknown): ExtensionActivity {
+ if (typeof value !== "object" || value === null) return {};
+ const repository = value as Record;
+ return {
+ stars:
+ typeof repository.stargazers_count === "number" ? repository.stargazers_count : undefined,
+ pushedAt: typeof repository.pushed_at === "string" ? repository.pushed_at : undefined,
+ createdAt: typeof repository.created_at === "string" ? repository.created_at : undefined,
+ };
+}
+
+/** Index one topic-search response by lowercased `owner/name`. */
+export function indexActivityByRepo(payload: unknown): Map {
+ const items =
+ typeof payload === "object" && payload !== null
+ ? (payload as { items?: unknown }).items
+ : undefined;
+ if (!Array.isArray(items)) return new Map();
+
+ const byRepo = new Map();
+ for (const item of items) {
+ const fullName =
+ typeof item === "object" && item !== null
+ ? (item as { full_name?: unknown }).full_name
+ : undefined;
+ if (typeof fullName !== "string") continue;
+ byRepo.set(fullName.toLowerCase(), readActivity(item));
+ }
+
+ return byRepo;
+}
+
+/** Build the one GitHub topic-search URL used by builds and the cached endpoint. */
+export function githubTopicActivityUrl() {
+ const query = encodeURIComponent(`topic:${HUNK_EXTENSION_TOPIC} is:public`);
+ return `https://api.github.com/search/repositories?q=${query}&per_page=100`;
+}
+
+/** Serialize indexed GitHub facts into the compact first-party response shape. */
+export function createExtensionActivityPayload(
+ activity: ReadonlyMap,
+ fetchedAt = new Date(),
+): ExtensionActivityPayload {
+ return {
+ fetchedAt: fetchedAt.toISOString(),
+ repositories: [...activity].map(([repo, facts]) => ({ repo, ...facts })),
+ };
+}
+
+/** Index one first-party activity response, ignoring malformed records. */
+export function indexPublishedActivity(payload: unknown): Map {
+ const repositories =
+ typeof payload === "object" && payload !== null
+ ? (payload as { repositories?: unknown }).repositories
+ : undefined;
+ if (!Array.isArray(repositories)) return new Map();
+
+ const activity = new Map();
+ for (const record of repositories) {
+ if (typeof record !== "object" || record === null) continue;
+ const value = record as Record;
+ if (typeof value.repo !== "string") continue;
+ activity.set(value.repo.toLowerCase(), {
+ stars: typeof value.stars === "number" ? value.stars : undefined,
+ pushedAt: typeof value.pushedAt === "string" ? value.pushedAt : undefined,
+ createdAt: typeof value.createdAt === "string" ? value.createdAt : undefined,
+ });
+ }
+ return activity;
+}
diff --git a/website/src/data/extensions.ts b/website/src/data/extensions.ts
index 3eac8890a..4f163784b 100644
--- a/website/src/data/extensions.ts
+++ b/website/src/data/extensions.ts
@@ -1,3 +1,22 @@
+import {
+ HUNK_EXTENSION_TOPIC,
+ type ExtensionActivity,
+ githubTopicActivityUrl,
+ indexActivityByRepo,
+ readActivity,
+} from "./extensionActivity";
+
+export {
+ HUNK_EXTENSION_TOPIC,
+ type ExtensionActivity,
+ createExtensionActivityPayload,
+ formatUpdated,
+ githubTopicActivityUrl,
+ indexActivityByRepo,
+ indexPublishedActivity,
+ readActivity,
+} from "./extensionActivity";
+
/**
* Curated seed for the hunk.dev extension directory.
*
@@ -10,9 +29,6 @@
* simply omitted when the fetch fails.
*/
-/** GitHub topic an author adds to be listed. */
-export const HUNK_EXTENSION_TOPIC = "hunk-extension";
-
/**
* What an extension registers, in the words the docs use for those surfaces.
*
@@ -47,13 +63,6 @@ export interface ExtensionListing {
apiVersion: number;
}
-/** Repository facts fetched at build time, absent when GitHub is unreachable. */
-export interface ExtensionActivity {
- stars?: number;
- pushedAt?: string;
- createdAt?: string;
-}
-
/** One listing as the page renders it. */
export type ExtensionEntry = ExtensionListing & ExtensionActivity;
@@ -194,19 +203,6 @@ export function categoryFacets(entries: readonly ExtensionListing[]) {
.sort((a, b) => b.count - a.count || a.category.localeCompare(b.category));
}
-/** Phrase one ISO timestamp as the coarse recency a directory card wants. */
-export function formatUpdated(pushedAt: string, now = new Date()) {
- const days = Math.floor((now.getTime() - new Date(pushedAt).getTime()) / 86_400_000);
- if (!Number.isFinite(days) || days < 0) return undefined;
- if (days === 0) return "today";
- if (days === 1) return "yesterday";
- if (days < 30) return `${days} days ago`;
- const months = Math.floor(days / 30);
- if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`;
- const years = Math.floor(days / 365);
- return `${years} year${years === 1 ? "" : "s"} ago`;
-}
-
/** Headers GitHub wants, with the build's token when it has one. */
function githubHeaders() {
const token = process.env.GITHUB_TOKEN;
@@ -216,54 +212,13 @@ function githubHeaders() {
};
}
-/** Read one repository's volatile facts out of a GitHub API repository object. */
-export function readActivity(value: unknown): ExtensionActivity {
- if (typeof value !== "object" || value === null) return {};
- const repository = value as Record;
- return {
- stars:
- typeof repository.stargazers_count === "number" ? repository.stargazers_count : undefined,
- pushedAt: typeof repository.pushed_at === "string" ? repository.pushed_at : undefined,
- createdAt: typeof repository.created_at === "string" ? repository.created_at : undefined,
- };
-}
-
-/**
- * Index one topic-search response by lowercased `owner/name`.
- *
- * The search is the same query the future indexer runs, so one request covers
- * every tagged repository however long the catalog gets — where a request per
- * listing would exhaust an unauthenticated build's hourly budget well before a
- * hundred listings and lose every star count at once.
- */
-export function indexActivityByRepo(payload: unknown): Map {
- const items =
- typeof payload === "object" && payload !== null
- ? (payload as { items?: unknown }).items
- : undefined;
- if (!Array.isArray(items)) return new Map();
-
- const byRepo = new Map();
- for (const item of items) {
- const fullName =
- typeof item === "object" && item !== null
- ? (item as { full_name?: unknown }).full_name
- : undefined;
- if (typeof fullName !== "string") continue;
- byRepo.set(fullName.toLowerCase(), readActivity(item));
- }
-
- return byRepo;
-}
-
/** Search the topic for every tagged repository's current metadata. */
async function fetchTopicActivity(): Promise
-
+
{
entries.map((entry) => {
const updated = entry.pushedAt ? formatUpdated(entry.pushedAt) : undefined;
@@ -193,17 +193,25 @@ const schema = {
- {typeof entry.stars === "number" && (
-
-
- ★
-
- {entry.stars.toLocaleString("en-US")}
+
+
+ ★
+
+
+ {typeof entry.stars === "number"
+ ? entry.stars.toLocaleString("en-US")
+ : ""}
- )}
+
v{entry.version}
API v{entry.apiVersion}
- {updated && updated {updated}}
+
+ updated {updated ?? ""}
+
);
@@ -220,31 +228,42 @@ const schema = {
-