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> { - const query = encodeURIComponent(`topic:${HUNK_EXTENSION_TOPIC} is:public`); try { - const response = await fetch( - `https://api.github.com/search/repositories?q=${query}&per_page=100`, - { headers: githubHeaders(), signal: AbortSignal.timeout(8000) }, - ); + const response = await fetch(githubTopicActivityUrl(), { + headers: githubHeaders(), + signal: AbortSignal.timeout(8000), + }); if (!response.ok) { console.warn( `Extension directory: GitHub topic search returned ${response.status}; rendering without stars.`, diff --git a/website/src/pages/extensions.astro b/website/src/pages/extensions.astro index 87d54bae2..d4473b2ee 100644 --- a/website/src/pages/extensions.astro +++ b/website/src/pages/extensions.astro @@ -139,7 +139,7 @@ const schema = { {entries.length} {entries.length === 1 ? "extension" : "extensions"}

-
+
{ 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")} + v{entry.version} API v{entry.apiVersion} - {updated && updated {updated}} +
); @@ -220,31 +228,42 @@ const schema = {
- diff --git a/website/tests/extensions-smoke.spec.ts b/website/tests/extensions-smoke.spec.ts index 3a8a40f54..b3052ea42 100644 --- a/website/tests/extensions-smoke.spec.ts +++ b/website/tests/extensions-smoke.spec.ts @@ -1,6 +1,13 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; +const ACTIVITY_URL = "**/api/extension-activity"; + +test.beforeEach(async ({ page }) => { + // Browser metadata refresh is best-effort; most UI tests exercise the static fallback. + await page.route(ACTIVITY_URL, (route) => route.abort()); +}); + test("the extension directory lists installable extensions", async ({ page }) => { await page.goto("/extensions/"); await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); @@ -38,6 +45,37 @@ test("search and category filters narrow the grid", async ({ page }) => { await expect(page.locator("#x-empty")).toBeVisible(); }); +test("cached activity refreshes star counts and card order", async ({ page }) => { + await page.unroute(ACTIVITY_URL); + await page.route(ACTIVITY_URL, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + fetchedAt: new Date().toISOString(), + repositories: [ + { + repo: "elucid/hunk-less-search", + stars: 12_345, + pushedAt: new Date().toISOString(), + createdAt: "2026-08-16T22:57:51Z", + }, + ], + }), + }), + ); + + await page.goto("/extensions/"); + const card = page.locator('[data-name="elucid/hunk-less-search"]'); + await expect(card).toHaveAttribute("data-stars", "12345"); + await expect(card.locator("[data-star-stat]")).toBeVisible(); + await expect(card.locator("[data-star-value]")).toHaveText("12,345"); + await expect(card.locator("[data-updated-stat]")).toHaveText("updated today"); + await expect(page.locator(".xcard").first()).toHaveAttribute( + "data-name", + "elucid/hunk-less-search", + ); +}); + test("sorting reorders the same cards without dropping any", async ({ page }) => { await page.goto("/extensions/"); const visible = page.locator(".xcard:not([hidden])");