Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-extension-stars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Keep community extension stars and update dates fresh between hunk.dev deployments.
2 changes: 1 addition & 1 deletion .github/workflows/website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions api/extension-activity.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response>,
) {
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");
});
});
72 changes: 72 additions & 0 deletions api/extension-activity.ts
Original file line number Diff line number Diff line change
@@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Direct environment access bypasses validation

The new function reads GITHUB_TOKEN directly from process.env, bypassing the repository’s required Varlock validation and leaving configuration mistakes unchecked until deployment.

Context Used: guidelines.mdc Cursor rule (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: api/extension-activity.ts
Line: 15

Comment:
**Direct environment access bypasses validation**

The new function reads `GITHUB_TOKEN` directly from `process.env`, bypassing the repository’s required Varlock validation and leaving configuration mistakes unchecked until deployment.

**Context Used:** guidelines.mdc Cursor rule ([source](https://github.com/modem-dev/modem/blob/main/.cursor/rules/guidelines.mdc))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

...(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);
},
};
35 changes: 35 additions & 0 deletions scripts/check-extension-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
EXTENSION_CATALOG,
avatarUrl,
categoryFacets,
createExtensionActivityPayload,
formatUpdated,
indexActivityByRepo,
indexPublishedActivity,
installCommand,
ownerOf,
repositoryUrl,
Expand Down Expand Up @@ -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: "</script><img src=x onerror=alert(1)>" });

Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"noUnusedParameters": false
},
"include": [
"api/**/*.ts",
"src/**/*.ts",
"src/**/*.tsx",
"scripts/**/*.ts",
Expand Down
2 changes: 1 addition & 1 deletion vercel.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 7 additions & 4 deletions website/src/content/docs/docs/help/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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.

Expand All @@ -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
```

Expand Down
105 changes: 105 additions & 0 deletions website/src/data/extensionActivity.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, ExtensionActivity> {
const items =
typeof payload === "object" && payload !== null
? (payload as { items?: unknown }).items
: undefined;
if (!Array.isArray(items)) return new Map();

const byRepo = new Map<string, ExtensionActivity>();
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<string, ExtensionActivity>,
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<string, ExtensionActivity> {
const repositories =
typeof payload === "object" && payload !== null
? (payload as { repositories?: unknown }).repositories
: undefined;
if (!Array.isArray(repositories)) return new Map();

const activity = new Map<string, ExtensionActivity>();
for (const record of repositories) {
if (typeof record !== "object" || record === null) continue;
const value = record as Record<string, unknown>;
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;
}
Loading
Loading