diff --git a/.env.example b/.env.example
deleted file mode 100644
index 6d8c042..0000000
--- a/.env.example
+++ /dev/null
@@ -1,5 +0,0 @@
-# Client-side ClickHouse fetching (optional)
-NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE=true
-
-# GitHub API access token (optional)
-GITHUB_TOKEN=your_github_personal_access_token_here
diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index e70779c..0000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "extends": ["next/core-web-vitals", "next/typescript"],
- "rules": {
- "no-unused-vars": "off",
- "@typescript-eslint/no-explicit-any": "off"
- }
-}
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..e6a8706
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,44 @@
+name: Deploy
+
+on:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+# Run one deploy at a time so an older commit can never finish after a newer one
+concurrency:
+ group: deploy
+ cancel-in-progress: false
+
+jobs:
+ test:
+ uses: ./.github/workflows/test.yml
+
+ deploy:
+ needs: test
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Deploy to Cloudflare
+ run: bun run deploy
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f3852a1..e59590f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,10 +1,10 @@
name: Run Tests
on:
- push:
- branches: [main]
pull_request:
branches: [main]
+ # Pushes to main run these tests through the Deploy workflow
+ workflow_call:
jobs:
test:
@@ -14,6 +14,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
@@ -27,3 +32,6 @@ jobs:
- name: Run lint
run: bun run lint
+
+ - name: Run build
+ run: bun run build
diff --git a/.gitignore b/.gitignore
index 26c66e4..873cbb0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,12 +13,8 @@
# testing
/coverage
-# next.js
-/.next/
-/out/
-
# production
-/build
+/dist/
# misc
.DS_Store
@@ -33,9 +29,9 @@ yarn-error.log*
.env*
!.env.example
-# vercel
-.vercel
+# cloudflare
+.wrangler
+.dev.vars*
# typescript
*.tsbuildinfo
-next-env.d.ts
diff --git a/README.md b/README.md
index e2ed026..d4ceadb 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
## Getting Started
-Install node v20+, bun v1.2+, then install package dependencies:
+Install node v22+, bun v1.2+, then install package dependencies:
```bash
bun install
@@ -12,24 +12,25 @@ Run the development server:
bun dev
```
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+Open [http://localhost:5173](http://localhost:5173) with your browser to see the result.
-## Environment Variables
+## Deployment
-Create a `.env.local` file with the following variables:
+The site is a static single-page app (Vite + React Router) served from Cloudflare Workers static assets. There is no server code: the browser queries ClickHouse directly, and paths like `/org/repo` fall back to `index.html` so the client-side router can handle them. Routing and domains are configured in `wrangler.jsonc`, response headers in `public/_headers`.
-```bash
-# Client-side ClickHouse fetching (optional)
-NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE=true
+Preview the production build locally in the Workers runtime:
-# GitHub API access (optional - for rate limiting)
-GITHUB_TOKEN=your_github_personal_access_token_here
+```bash
+bun run preview
```
-### Environment Variable Details:
+Every push to `main` is tested and then deployed by GitHub Actions (`.github/workflows/deploy.yml`). It needs two repository secrets: `CLOUDFLARE_API_TOKEN`, created from Cloudflare's "Edit Cloudflare Workers" token template with both zones included, and `CLOUDFLARE_ACCOUNT_ID`.
+
+To deploy from your machine instead (run `bunx wrangler login` once first):
-- `NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE`: Set to `true` to fetch ClickHouse data directly from the browser instead of the server. This reduces server load and provides faster navigation between pages.
-- `GITHUB_TOKEN`: Optional GitHub personal access token to increase API rate limits when fetching repository metadata.
+```bash
+bun run deploy
+```
## Update node packages
diff --git a/app/[...githubPath]/page.test.tsx b/app/[...githubPath]/page.test.tsx
deleted file mode 100644
index 686fdfb..0000000
--- a/app/[...githubPath]/page.test.tsx
+++ /dev/null
@@ -1,245 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { redirect } from "next/navigation";
-import GitHubPathPage from "./page";
-
-vi.mock("next/navigation", () => ({
- redirect: vi.fn(),
-}));
-
-vi.mock("@/utils/github", () => ({
- parseGitHubURL: vi.fn(),
-}));
-
-import { parseGitHubURL } from "@/utils/github";
-
-const mockedRedirect = vi.mocked(redirect);
-const mockedParseGitHubURL = vi.mocked(parseGitHubURL);
-
-describe("GitHubPathPage", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- afterEach(() => {
- vi.resetAllMocks();
- });
-
- describe("when GitHub path is valid", () => {
- it("redirects to correct org/repo route for simple repository", async () => {
- mockedParseGitHubURL.mockReturnValue("facebook/react");
-
- const params = Promise.resolve({ githubPath: ["facebook", "react"] });
-
- await GitHubPathPage({ params });
-
- expect(mockedParseGitHubURL).toHaveBeenCalledWith(
- "https://github.com/facebook/react"
- );
- expect(mockedRedirect).toHaveBeenCalledWith("/facebook/react");
- });
-
- it("redirects for repository with special characters", async () => {
- mockedParseGitHubURL.mockReturnValue("vercel/next.js");
-
- const params = Promise.resolve({ githubPath: ["vercel", "next.js"] });
-
- await GitHubPathPage({ params });
-
- expect(mockedParseGitHubURL).toHaveBeenCalledWith(
- "https://github.com/vercel/next.js"
- );
- expect(mockedRedirect).toHaveBeenCalledWith("/vercel/next.js");
- });
-
- it("redirects for repository with hyphens", async () => {
- mockedParseGitHubURL.mockReturnValue("facebook/react-native");
-
- const params = Promise.resolve({
- githubPath: ["facebook", "react-native"],
- });
-
- await GitHubPathPage({ params });
-
- expect(mockedParseGitHubURL).toHaveBeenCalledWith(
- "https://github.com/facebook/react-native"
- );
- expect(mockedRedirect).toHaveBeenCalledWith("/facebook/react-native");
- });
-
- it("handles complex paths with additional segments", async () => {
- mockedParseGitHubURL.mockReturnValue("microsoft/typescript");
-
- const params = Promise.resolve({
- githubPath: ["microsoft", "typescript", "tree", "main", "src"],
- });
-
- await GitHubPathPage({ params });
-
- expect(mockedParseGitHubURL).toHaveBeenCalledWith(
- "https://github.com/microsoft/typescript/tree/main/src"
- );
- expect(mockedRedirect).toHaveBeenCalledWith("/microsoft/typescript");
- });
-
- it("handles single segment paths", async () => {
- mockedParseGitHubURL.mockReturnValue("nodejs/node");
-
- const params = Promise.resolve({ githubPath: ["nodejs", "node"] });
-
- await GitHubPathPage({ params });
-
- expect(mockedParseGitHubURL).toHaveBeenCalledWith(
- "https://github.com/nodejs/node"
- );
- expect(mockedRedirect).toHaveBeenCalledWith("/nodejs/node");
- });
- });
-
- describe("when GitHub path is invalid", () => {
- it("returns error message when parseGitHubURL returns null", async () => {
- mockedParseGitHubURL.mockReturnValue(null);
-
- const params = Promise.resolve({ githubPath: ["invalid"] });
-
- const result = await GitHubPathPage({ params });
-
- expect(mockedParseGitHubURL).toHaveBeenCalledWith(
- "https://github.com/invalid"
- );
- expect(mockedRedirect).not.toHaveBeenCalled();
- // Check that it returns JSX with the error message
- expect(result).toBeDefined();
- expect(result.type).toBe("div");
- expect(result.props.children).toBe(
- "Invalid GitHub URL. Please enter a valid GitHub repository."
- );
- });
-
- it("returns error message for empty path", async () => {
- mockedParseGitHubURL.mockReturnValue(null);
-
- const params = Promise.resolve({ githubPath: [] });
-
- const result = await GitHubPathPage({ params });
-
- expect(mockedParseGitHubURL).toHaveBeenCalledWith("https://github.com/");
- expect(mockedRedirect).not.toHaveBeenCalled();
- // Check that it returns JSX with the error message
- expect(result).toBeDefined();
- expect(result.type).toBe("div");
- expect(result.props.children).toBe(
- "Invalid GitHub URL. Please enter a valid GitHub repository."
- );
- });
-
- it("returns error message for malformed repository names", async () => {
- mockedParseGitHubURL.mockReturnValue(null);
-
- const params = Promise.resolve({
- githubPath: ["not-a-valid-repo-format"],
- });
-
- const result = await GitHubPathPage({ params });
-
- expect(mockedParseGitHubURL).toHaveBeenCalledWith(
- "https://github.com/not-a-valid-repo-format"
- );
- expect(mockedRedirect).not.toHaveBeenCalled();
- // Check that it returns JSX with the error message
- expect(result).toBeDefined();
- expect(result.type).toBe("div");
- expect(result.props.children).toBe(
- "Invalid GitHub URL. Please enter a valid GitHub repository."
- );
- });
- });
-
- describe("edge cases", () => {
- it("handles URLs with encoded characters", async () => {
- mockedParseGitHubURL.mockReturnValue("user/repo-with-special-chars");
-
- const params = Promise.resolve({
- githubPath: ["user", "repo%20with%20spaces"],
- });
-
- await GitHubPathPage({ params });
-
- expect(mockedParseGitHubURL).toHaveBeenCalledWith(
- "https://github.com/user/repo%20with%20spaces"
- );
- expect(mockedRedirect).toHaveBeenCalledWith(
- "/user/repo-with-special-chars"
- );
- });
-
- it("handles very long paths", async () => {
- mockedParseGitHubURL.mockReturnValue("org/repo");
-
- const longPath = [
- "org",
- "repo",
- "tree",
- "main",
- "src",
- "components",
- "ui",
- "button",
- ];
- const params = Promise.resolve({ githubPath: longPath });
-
- await GitHubPathPage({ params });
-
- expect(mockedParseGitHubURL).toHaveBeenCalledWith(
- `https://github.com/${longPath.join("/")}`
- );
- expect(mockedRedirect).toHaveBeenCalledWith("/org/repo");
- });
-
- it("handles path with only organization name", async () => {
- mockedParseGitHubURL.mockReturnValue(null);
-
- const params = Promise.resolve({ githubPath: ["facebook"] });
-
- const result = await GitHubPathPage({ params });
-
- expect(mockedParseGitHubURL).toHaveBeenCalledWith(
- "https://github.com/facebook"
- );
- expect(mockedRedirect).not.toHaveBeenCalled();
- // Check that it returns JSX with the error message
- expect(result).toBeDefined();
- expect(result.type).toBe("div");
- expect(result.props.children).toBe(
- "Invalid GitHub URL. Please enter a valid GitHub repository."
- );
- });
- });
-
- describe("parseGitHubURL integration", () => {
- it("properly constructs GitHub URLs for parsing", async () => {
- const testCases = [
- {
- input: ["microsoft", "typescript"],
- expected: "https://github.com/microsoft/typescript",
- },
- {
- input: ["vercel", "next.js", "blob", "main", "README.md"],
- expected: "https://github.com/vercel/next.js/blob/main/README.md",
- },
- {
- input: ["facebook", "react", "issues"],
- expected: "https://github.com/facebook/react/issues",
- },
- ];
-
- for (const testCase of testCases) {
- mockedParseGitHubURL.mockReturnValue("owner/repo");
-
- const params = Promise.resolve({ githubPath: testCase.input });
- await GitHubPathPage({ params });
-
- expect(mockedParseGitHubURL).toHaveBeenCalledWith(testCase.expected);
- }
- });
- });
-});
diff --git a/app/[...githubPath]/page.tsx b/app/[...githubPath]/page.tsx
deleted file mode 100644
index 209b3c9..0000000
--- a/app/[...githubPath]/page.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import React from "react";
-import { redirect } from "next/navigation";
-import { parseGitHubURL } from "@/utils/github";
-
-export default async function GitHubPathPage({
- params,
-}: {
- params: Promise<{ githubPath: string[] }>;
-}) {
- const githubPath = (await params).githubPath;
- const fullPath = githubPath.join("/");
- const repoName = parseGitHubURL(`https://github.com/${fullPath}`);
-
- if (repoName) {
- // Split the repo name into org and repo components and redirect to the correct route
- const [org, repo] = repoName.split("/");
- return redirect(`/${org}/${repo}`);
- }
-
- return
Invalid GitHub URL. Please enter a valid GitHub repository.
;
-}
diff --git a/app/[org]/[repo]/loading.tsx b/app/[org]/[repo]/loading.tsx
deleted file mode 100644
index 3f75c3a..0000000
--- a/app/[org]/[repo]/loading.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Skeleton } from "@/app/components/Skeleton";
-
-export default function Loading() {
- return (
-
-
-
-
-
-
- {[...Array(4)].map((_, i) => (
-
- ))}
-
-
-
- {[...Array(10)].map((_, i) => (
-
-
- {[...Array(4)].map((_, j) => (
-
- ))}
-
-
- ))}
-
-
-
-
- );
-}
diff --git a/app/[org]/[repo]/page.test.tsx b/app/[org]/[repo]/page.test.tsx
deleted file mode 100644
index 59cb97c..0000000
--- a/app/[org]/[repo]/page.test.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { generateMetadata } from "./page";
-
-describe("RepoPage", () => {
- describe("generateMetadata", () => {
- it("generates correct metadata for repository", async () => {
- const params = Promise.resolve({ org: "facebook", repo: "react" });
-
- const metadata = await generateMetadata({ params });
-
- expect(metadata).toEqual({
- title: "facebook/react Related Repos - GitRelate(d)",
- });
- });
-
- it("generates metadata for different repository names", async () => {
- const testCases = [
- {
- org: "microsoft",
- repo: "typescript",
- expected: "microsoft/typescript Related Repos - GitRelate(d)",
- },
- {
- org: "vercel",
- repo: "next.js",
- expected: "vercel/next.js Related Repos - GitRelate(d)",
- },
- {
- org: "nodejs",
- repo: "node",
- expected: "nodejs/node Related Repos - GitRelate(d)",
- },
- {
- org: "facebook",
- repo: "react-native",
- expected: "facebook/react-native Related Repos - GitRelate(d)",
- },
- ];
-
- for (const { org, repo, expected } of testCases) {
- const params = Promise.resolve({ org, repo });
- const metadata = await generateMetadata({ params });
-
- expect(metadata).toEqual({
- title: expected,
- });
- }
- });
-
- it("handles special characters in repository names", async () => {
- const params = Promise.resolve({
- org: "user",
- repo: "repo-with-special.chars_123",
- });
-
- const metadata = await generateMetadata({ params });
-
- expect(metadata).toEqual({
- title: "user/repo-with-special.chars_123 Related Repos - GitRelate(d)",
- });
- });
-
- it("handles repository names with dots", async () => {
- const params = Promise.resolve({ org: "vercel", repo: "next.js" });
-
- const metadata = await generateMetadata({ params });
-
- expect(metadata).toEqual({
- title: "vercel/next.js Related Repos - GitRelate(d)",
- });
- });
-
- it("handles repository names with underscores and hyphens", async () => {
- const params = Promise.resolve({ org: "some-org", repo: "my_repo" });
-
- const metadata = await generateMetadata({ params });
-
- expect(metadata).toEqual({
- title: "some-org/my_repo Related Repos - GitRelate(d)",
- });
- });
-
- it("handles long repository names", async () => {
- const params = Promise.resolve({
- org: "organization-with-long-name",
- repo: "repository-with-very-long-name-containing-multiple-words",
- });
-
- const metadata = await generateMetadata({ params });
-
- expect(metadata).toEqual({
- title:
- "organization-with-long-name/repository-with-very-long-name-containing-multiple-words Related Repos - GitRelate(d)",
- });
- });
- });
-});
diff --git a/app/[org]/[repo]/page.tsx b/app/[org]/[repo]/page.tsx
deleted file mode 100644
index 7f17f65..0000000
--- a/app/[org]/[repo]/page.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { getRelatedReposCached as getRelatedRepos } from "@/lib/repos";
-import RepoTable from "@/app/components/RepoTable";
-
-export default async function RepoPage({
- params,
-}: {
- params: Promise<{ org: string; repo: string }>;
-}) {
- const { org, repo } = await params;
- const repoName = `${org}/${repo}`;
-
- const useClient = process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE === "true";
-
- if (useClient) {
- return (
-
-
- Related Repositories for {repoName}
-
-
-
- );
- }
-
- try {
- const relatedRepos = await getRelatedRepos(repoName, 0);
-
- if (!relatedRepos.length) {
- return (
-
-
- No Related Repositories Found
-
-
- We couldn't find any related repositories for {repoName}. This
- could be because:
-
-
- The repository is too new
- It has very few stargazers
- The data is still being processed
-
-
- );
- }
-
- return (
-
-
- Related Repositories for {repoName}
-
-
-
- );
- } catch (error) {
- const err = error as Error;
- console.error("Error fetching related repos:", err);
- return (
-
-
Error Loading Data
-
- {err.message === "Request timed out. Please try again."
- ? "The request took too long to complete. Please try again or try a different repository."
- : "There was an error loading the related repositories. Please try again later."}
-
-
- );
- }
-}
-
-export async function generateMetadata({
- params,
-}: {
- params: Promise<{ org: string; repo: string }>;
-}) {
- const { org, repo } = await params;
- const repoName = `${org}/${repo}`;
- return {
- title: `${repoName} Related Repos - GitRelate(d)`,
- };
-}
diff --git a/app/actions.ts b/app/actions.ts
deleted file mode 100644
index 1e9f127..0000000
--- a/app/actions.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-"use server";
-
-import { getRelatedReposCached } from "@/lib/repos";
-
-export async function fetchMoreRelatedRepos(repoName: string, offset: number) {
- return await getRelatedReposCached(repoName, offset);
-}
diff --git a/app/api/stargazers/route.test.ts b/app/api/stargazers/route.test.ts
deleted file mode 100644
index 8c20851..0000000
--- a/app/api/stargazers/route.test.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-import { NextRequest } from "next/server";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { GET } from "./route";
-
-vi.mock("@/lib/github", () => ({
- getStargazers: vi.fn(),
-}));
-
-import { getStargazers } from "@/lib/github";
-
-const mockedGetStargazers = vi.mocked(getStargazers);
-
-describe("Stargazers API Route", () => {
- const originalEnv = process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE;
-
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- afterEach(() => {
- // Restore original environment variable
- if (originalEnv !== undefined) {
- process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE = originalEnv;
- } else {
- delete process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE;
- }
- vi.resetAllMocks();
- });
-
- describe("when client-side fetching is enabled", () => {
- beforeEach(() => {
- process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE = "true";
- });
-
- it("returns informational message about client-side fetching", async () => {
- const request = new NextRequest(
- "http://localhost:3000/api/stargazers?repo=owner/repo"
- );
-
- const response = await GET(request);
- const data = await response.json();
-
- expect(response.status).toBe(200);
- expect(data).toEqual({
- message:
- "Client-side fetching is enabled. Data is fetched directly from ClickHouse in the browser for better performance.",
- suggestion:
- "This API endpoint is available but not recommended when client-side fetching is enabled.",
- clientFetchingEnabled: true,
- });
-
- // Should not call the actual API function
- expect(mockedGetStargazers).not.toHaveBeenCalled();
- });
-
- it("returns the same message regardless of query parameters", async () => {
- const request = new NextRequest("http://localhost:3000/api/stargazers");
-
- const response = await GET(request);
- const data = await response.json();
-
- expect(response.status).toBe(200);
- expect(data.clientFetchingEnabled).toBe(true);
- expect(mockedGetStargazers).not.toHaveBeenCalled();
- });
- });
-
- describe("when client-side fetching is disabled", () => {
- beforeEach(() => {
- process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE = "false";
- });
-
- it("returns stargazers data when repo parameter is provided", async () => {
- const mockStargazers = [
- { login: "user1", avatar_url: "https://github.com/user1.png" },
- { login: "user2", avatar_url: "https://github.com/user2.png" },
- ];
-
- mockedGetStargazers.mockResolvedValue(mockStargazers);
-
- const request = new NextRequest(
- "http://localhost:3000/api/stargazers?repo=facebook/react"
- );
-
- const response = await GET(request);
- const data = await response.json();
-
- expect(response.status).toBe(200);
- expect(data).toEqual(mockStargazers);
- expect(mockedGetStargazers).toHaveBeenCalledWith("facebook/react");
- });
-
- it("returns 400 error when repo parameter is missing", async () => {
- const request = new NextRequest("http://localhost:3000/api/stargazers");
-
- const response = await GET(request);
- const data = await response.json();
-
- expect(response.status).toBe(400);
- expect(data).toEqual({ error: "Repo parameter is required" });
- expect(mockedGetStargazers).not.toHaveBeenCalled();
- });
-
- it("returns 400 error when repo parameter is empty", async () => {
- const request = new NextRequest(
- "http://localhost:3000/api/stargazers?repo="
- );
-
- const response = await GET(request);
- const data = await response.json();
-
- expect(response.status).toBe(400);
- expect(data).toEqual({ error: "Repo parameter is required" });
- expect(mockedGetStargazers).not.toHaveBeenCalled();
- });
-
- it("handles errors from getStargazers function", async () => {
- const consoleErrorSpy = vi
- .spyOn(console, "error")
- .mockImplementation(() => {});
- const error = new Error("GitHub API error");
- mockedGetStargazers.mockRejectedValue(error);
-
- const request = new NextRequest(
- "http://localhost:3000/api/stargazers?repo=owner/repo"
- );
-
- const response = await GET(request);
- const data = await response.json();
-
- expect(response.status).toBe(500);
- expect(data).toEqual({ error: "Failed to fetch stargazers" });
- expect(mockedGetStargazers).toHaveBeenCalledWith("owner/repo");
- expect(consoleErrorSpy).toHaveBeenCalledWith(
- "Error fetching stargazers:",
- error
- );
-
- consoleErrorSpy.mockRestore();
- });
-
- it("handles various repo name formats", async () => {
- const mockStargazers = [
- { login: "user1", avatar_url: "https://github.com/user1.png" },
- ];
- mockedGetStargazers.mockResolvedValue(mockStargazers);
-
- const testCases = [
- "microsoft/typescript",
- "vercel/next.js",
- "facebook/react-native",
- "nodejs/node",
- ];
-
- for (const repo of testCases) {
- const request = new NextRequest(
- `http://localhost:3000/api/stargazers?repo=${repo}`
- );
- const response = await GET(request);
-
- expect(response.status).toBe(200);
- expect(mockedGetStargazers).toHaveBeenCalledWith(repo);
- }
- });
- });
-
- describe("when client-side fetching environment variable is not set", () => {
- beforeEach(() => {
- delete process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE;
- });
-
- it("falls back to server-side implementation", async () => {
- const mockStargazers = [
- { login: "user1", avatar_url: "https://github.com/user1.png" },
- ];
- mockedGetStargazers.mockResolvedValue(mockStargazers);
-
- const request = new NextRequest(
- "http://localhost:3000/api/stargazers?repo=owner/repo"
- );
-
- const response = await GET(request);
- const data = await response.json();
-
- expect(response.status).toBe(200);
- expect(data).toEqual(mockStargazers);
- expect(mockedGetStargazers).toHaveBeenCalledWith("owner/repo");
- });
- });
-});
diff --git a/app/api/stargazers/route.ts b/app/api/stargazers/route.ts
deleted file mode 100644
index 4c10016..0000000
--- a/app/api/stargazers/route.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { getStargazers } from "@/lib/github";
-import { NextRequest, NextResponse } from "next/server";
-
-export async function GET(req: NextRequest) {
- // Check if client-side fetching is enabled
- if (process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE === "true") {
- return NextResponse.json(
- {
- message:
- "Client-side fetching is enabled. Data is fetched directly from ClickHouse in the browser for better performance.",
- suggestion:
- "This API endpoint is available but not recommended when client-side fetching is enabled.",
- clientFetchingEnabled: true,
- },
- { status: 200 }
- );
- }
-
- // Client-side fetching is disabled, use original API implementation
- const { searchParams } = new URL(req.url);
- const repo = searchParams.get("repo");
-
- if (!repo) {
- return NextResponse.json(
- { error: "Repo parameter is required" },
- { status: 400 }
- );
- }
-
- try {
- const stargazers = await getStargazers(repo);
- return NextResponse.json(stargazers);
- } catch (error) {
- console.error("Error fetching stargazers:", error);
- return NextResponse.json(
- { error: "Failed to fetch stargazers" },
- { status: 500 }
- );
- }
-}
diff --git a/app/components/Header.tsx b/app/components/Header.tsx
index 283855b..aea1f09 100644
--- a/app/components/Header.tsx
+++ b/app/components/Header.tsx
@@ -1,4 +1,4 @@
-import Link from "next/link";
+import { Link } from "react-router";
export default function Header() {
return (
@@ -7,7 +7,7 @@ export default function Header() {
GitRelate(d)
diff --git a/app/components/RepoFilter.tsx b/app/components/RepoFilter.tsx
index ea76680..039ba8d 100644
--- a/app/components/RepoFilter.tsx
+++ b/app/components/RepoFilter.tsx
@@ -1,5 +1,3 @@
-"use client";
-
import { useState } from "react";
import { Switch } from "@headlessui/react";
diff --git a/app/components/RepoInput.tsx b/app/components/RepoInput.tsx
index a1b85f0..1aa72fe 100644
--- a/app/components/RepoInput.tsx
+++ b/app/components/RepoInput.tsx
@@ -1,15 +1,13 @@
-"use client";
-
import { useState } from "react";
-import { useRouter } from "next/navigation";
+import { useNavigate } from "react-router";
import { parseGitHubURL } from "@/utils/github";
export default function RepoInput() {
const [repo, setRepo] = useState("");
- const router = useRouter();
+ const navigate = useNavigate();
const handleSearch = () => {
- if (repo) router.push(`/${repo}`);
+ if (repo) navigate(`/${repo}`);
};
const handleInputChange = (e: React.ChangeEvent) => {
diff --git a/app/components/RepoTable.test.tsx b/app/components/RepoTable.test.tsx
new file mode 100644
index 0000000..2192bb2
--- /dev/null
+++ b/app/components/RepoTable.test.tsx
@@ -0,0 +1,105 @@
+// @vitest-environment jsdom
+import type { RelatedRepo } from "@/types/github";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import RepoTable from "./RepoTable";
+
+vi.mock("@/lib/repos.client", () => ({
+ getRelatedReposClient: vi.fn(),
+ clearExpiredCache: vi.fn(),
+}));
+
+import { getRelatedReposClient } from "@/lib/repos.client";
+
+const mockedGetRelatedReposClient = vi.mocked(getRelatedReposClient);
+
+const EMPTY_MESSAGE = "No related repositories found.";
+
+const mockRelatedRepos: RelatedRepo[] = [
+ {
+ repoName: "owner/repo1",
+ githubUrl: "https://github.com/owner/repo1",
+ stargazers: 100,
+ forkers: 50,
+ ratio: 2.0,
+ },
+];
+
+function renderTable() {
+ return render(
+
+
+
+ );
+}
+
+function isLoading(container: HTMLElement) {
+ return container.querySelector(".animate-pulse") !== null;
+}
+
+describe("RepoTable", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ vi.resetAllMocks();
+ });
+
+ it("shows a loading skeleton while the first page loads", () => {
+ mockedGetRelatedReposClient.mockReturnValue(new Promise(() => {}));
+
+ const { container } = renderTable();
+
+ expect(isLoading(container)).toBe(true);
+ expect(screen.queryByText(EMPTY_MESSAGE)).toBe(null);
+ });
+
+ it("lists the related repos once they load", async () => {
+ mockedGetRelatedReposClient.mockResolvedValue(mockRelatedRepos);
+
+ const { container } = renderTable();
+
+ expect(await screen.findByText("owner/repo1")).toBeDefined();
+ expect(isLoading(container)).toBe(false);
+ expect(mockedGetRelatedReposClient).toHaveBeenCalledWith("test/repo", 0);
+ });
+
+ it("shows the empty state when no related repos are found", async () => {
+ mockedGetRelatedReposClient.mockResolvedValue([]);
+
+ const { container } = renderTable();
+
+ expect(await screen.findByText(EMPTY_MESSAGE)).toBeDefined();
+ expect(isLoading(container)).toBe(false);
+ expect(screen.queryByRole("button", { name: "Show More" })).toBe(null);
+ });
+
+ it("shows the error and loads again when Try Again is clicked", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ mockedGetRelatedReposClient
+ .mockRejectedValueOnce(new Error("Request timed out. Please try again."))
+ .mockResolvedValueOnce(mockRelatedRepos);
+
+ const { container } = renderTable();
+
+ expect(await screen.findByText("Error Loading Data")).toBeDefined();
+ expect(
+ screen.getByText("Request timed out. Please try again.")
+ ).toBeDefined();
+
+ fireEvent.click(screen.getByRole("button", { name: "Try Again" }));
+
+ expect(isLoading(container)).toBe(true);
+ expect(await screen.findByText("owner/repo1")).toBeDefined();
+ expect(screen.queryByText("Error Loading Data")).toBe(null);
+ expect(mockedGetRelatedReposClient).toHaveBeenCalledTimes(2);
+ expect(mockedGetRelatedReposClient).toHaveBeenLastCalledWith(
+ "test/repo",
+ 0
+ );
+ });
+});
diff --git a/app/components/RepoTable.tsx b/app/components/RepoTable.tsx
index 6bfc1a1..41d67d4 100644
--- a/app/components/RepoTable.tsx
+++ b/app/components/RepoTable.tsx
@@ -1,15 +1,12 @@
-"use client";
-
import { useState, useEffect } from "react";
import { ArrowUpDown, Search } from "lucide-react";
-import Link from "next/link";
+import { Link } from "react-router";
import type { RelatedRepo } from "@/types/github";
-import { fetchMoreRelatedRepos } from "@/app/actions";
import { getRelatedReposClient, clearExpiredCache } from "@/lib/repos.client";
import { Skeleton } from "@/app/components/Skeleton";
// import RepoFilter from './RepoFilter';
-// Loading skeleton component for the repo table that matches the server-side loading
+// Loading skeleton component for the repo table
function RepoTableSkeleton() {
return (
//
@@ -42,43 +39,25 @@ function RepoTableSkeleton() {
);
}
-export default function RepoTable({
- initialRelatedRepos = [],
- repoName = "",
-}: {
- initialRelatedRepos?: RelatedRepo[];
- repoName?: string;
-}) {
- const [relatedRepos, setRelatedRepos] =
- useState
(initialRelatedRepos);
- // const [filteredRepos, setFilteredRepos] = useState(initialRelatedRepos);
- const [offset, setOffset] = useState(
- initialRelatedRepos?.length || 0
+export default function RepoTable({ repoName = "" }: { repoName?: string }) {
+ const [relatedRepos, setRelatedRepos] = useState([]);
+ // const [filteredRepos, setFilteredRepos] = useState([]);
+ const [offset, setOffset] = useState(0);
+ // Tracked separately from the results, because a loaded repo can have none
+ const [initialLoading, setInitialLoading] = useState(
+ Boolean(repoName)
);
- const [initialLoading, setInitialLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState("");
-
- const useClient = process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE === "true";
-
- // Determine if we should show loading initially
- const shouldShowInitialLoading =
- useClient &&
- repoName &&
- initialRelatedRepos.length === 0 &&
- relatedRepos.length === 0 &&
- !error;
+ // Incremented to run the initial fetch again after an error
+ const [attempt, setAttempt] = useState(0);
useEffect(() => {
- // Clear expired cache entries on component mount when using client-side fetching
- if (useClient) {
- clearExpiredCache();
- }
+ // Clear expired cache entries on component mount
+ clearExpiredCache();
- if (useClient && repoName && relatedRepos.length === 0) {
- setInitialLoading(true);
- setError("");
+ if (repoName) {
getRelatedReposClient(repoName, 0)
.then((data) => {
setRelatedRepos(data);
@@ -93,7 +72,7 @@ export default function RepoTable({
setInitialLoading(false);
});
}
- }, [useClient, repoName, relatedRepos.length]);
+ }, [repoName, attempt]);
const [sortColumn, setSortColumn] = useState("stargazers");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
@@ -153,9 +132,7 @@ export default function RepoTable({
const handleShowMore = async () => {
setLoadingMore(true);
try {
- const moreRepos = useClient
- ? await getRelatedReposClient(repoName, offset)
- : await fetchMoreRelatedRepos(repoName, offset);
+ const moreRepos = await getRelatedReposClient(repoName, offset);
if (moreRepos.length === 0 || moreRepos.length < 100) {
setHasMore(false);
}
@@ -171,7 +148,7 @@ export default function RepoTable({
};
// Show loading state for initial client-side fetch
- if (initialLoading || shouldShowInitialLoading) {
+ if (initialLoading) {
return ;
}
@@ -186,8 +163,8 @@ export default function RepoTable({
{
setError("");
- setRelatedRepos([]);
- setOffset(0);
+ setInitialLoading(true);
+ setAttempt((prev) => prev + 1);
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
@@ -261,8 +238,7 @@ export default function RepoTable({
{repo.repoName}
diff --git a/app/components/ThemeToggle.tsx b/app/components/ThemeToggle.tsx
index 2ffee67..01f1969 100644
--- a/app/components/ThemeToggle.tsx
+++ b/app/components/ThemeToggle.tsx
@@ -1,21 +1,9 @@
-"use client";
-
import { Moon, Sun } from "lucide-react";
-import { useTheme } from "next-themes";
-import { useEffect, useState } from "react";
+import { useTheme } from "@/app/theme";
export function ThemeToggle() {
- const [mounted, setMounted] = useState(false);
const { resolvedTheme, setTheme } = useTheme();
- useEffect(() => {
- setMounted(true);
- }, []);
-
- if (!mounted) {
- return
; // placeholder to prevent layout shift
- }
-
return (
setTheme(resolvedTheme === "dark" ? "light" : "dark")}
diff --git a/app/layout.tsx b/app/layout.tsx
index 4bc661a..db81d5e 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,30 +1,16 @@
-import type { Metadata } from "next";
-import { Providers } from "@/app/providers";
+import { Outlet, ScrollRestoration } from "react-router";
import Header from "@/app/components/Header";
import Footer from "@/app/components/Footer";
-import "./globals.css";
-export const metadata: Metadata = {
- title: "GitRelate(d) - Find Related Repositories",
- description: "Find related repositories on GitHub",
-};
-
-export default function RootLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
+export default function RootLayout() {
return (
-
-
-
-
-
- {children}
-
-
-
-
-
+ <>
+
+
+
+
+
+
+ >
);
}
diff --git a/app/main.tsx b/app/main.tsx
new file mode 100644
index 0000000..2bcba01
--- /dev/null
+++ b/app/main.tsx
@@ -0,0 +1,17 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { createBrowserRouter } from "react-router";
+import { RouterProvider } from "react-router/dom";
+import { Providers } from "@/app/providers";
+import { routes } from "@/app/routes";
+import "./globals.css";
+
+const router = createBrowserRouter(routes);
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+);
diff --git a/app/pages/GitHubPathPage.tsx b/app/pages/GitHubPathPage.tsx
new file mode 100644
index 0000000..18fa47f
--- /dev/null
+++ b/app/pages/GitHubPathPage.tsx
@@ -0,0 +1,17 @@
+import { Navigate, useLocation } from "react-router";
+import { parseGitHubURL } from "@/utils/github";
+import { useDocumentTitle } from "@/utils/useDocumentTitle";
+
+export default function GitHubPathPage() {
+ const { pathname } = useLocation();
+ const repoName = parseGitHubURL(`https://github.com${pathname}`);
+
+ useDocumentTitle("GitRelate(d) - Find Related Repositories");
+
+ if (repoName) {
+ // Drop any extra path segments (e.g. /tree/main) and go to the repo page
+ return ;
+ }
+
+ return Invalid GitHub URL. Please enter a valid GitHub repository.
;
+}
diff --git a/app/page.tsx b/app/pages/HomePage.tsx
similarity index 56%
rename from app/page.tsx
rename to app/pages/HomePage.tsx
index 2768ef6..98bc857 100644
--- a/app/page.tsx
+++ b/app/pages/HomePage.tsx
@@ -1,54 +1,12 @@
-import Link from "next/link";
+import { Link } from "react-router";
import RepoInput from "@/app/components/RepoInput";
-import type { Metadata } from "next";
-
-export const metadata: Metadata = {
- title: "GitRelate(d) - Find Related GitHub Repositories",
- description:
- "Discover GitHub repositories related to any repo based on users who starred them. Replace github.com with gitrelate.com in any repository URL to find similar projects and explore new tools.",
- keywords: [
- "github",
- "repositories",
- "related repos",
- "github discovery",
- "open source",
- "code discovery",
- "developer tools",
- ],
- alternates: {
- canonical: "https://gitrelated.com/",
- },
- openGraph: {
- title: "GitRelate(d) - Find Related GitHub Repositories",
- description:
- "Discover GitHub repositories related to any repo based on users who starred them. Replace github.com with gitrelate.com to find similar projects.",
- url: "https://gitrelated.com/",
- siteName: "GitRelate(d)",
- type: "website",
- locale: "en_US",
- images: [
- {
- url: "https://opengraph.githubassets.com/e77a7573cf968b6970ec190e2284ed4e6bd8211dc918487aec0ab1ae717f189a/tylercb/gitrelate",
- width: 1200,
- height: 600,
- alt: "GitRelate(d) - Find Related GitHub Repositories",
- },
- ],
- },
- twitter: {
- card: "summary",
- title: "GitRelate(d) - Find Related GitHub Repositories",
- description:
- "Discover GitHub repositories related to any repo based on users who starred them. Replace github.com with gitrelate.com to find similar projects.",
- creator: "@tylerhanway",
- },
- robots: {
- index: true,
- follow: false,
- },
-};
+import { useDocumentTitle } from "@/utils/useDocumentTitle";
+// The rest of this page's metadata (description, Open Graph, etc.) is static
+// and lives in index.html
export default function HomePage() {
+ useDocumentTitle("GitRelate(d) - Find Related GitHub Repositories");
+
return (
@@ -76,34 +34,34 @@ export default function HomePage() {
Works best with obscure niches with less than 1,000 stargazers. Repos
with many stargazers can take up to 30 seconds to load the ClickHouse
data and may return less relevant results. Try{" "}
-
+
mattiasthalen/ducklake
{" "}
or{" "}
-
+
matsonj/nba-monte-carlo
.
This project is open source and available on{" "}
-
GitHub
-
+
. Contributions and feedback are welcome! Discuss at{" "}
-
Hacker News
-
+
.
diff --git a/app/pages/RepoPage.tsx b/app/pages/RepoPage.tsx
new file mode 100644
index 0000000..83870fa
--- /dev/null
+++ b/app/pages/RepoPage.tsx
@@ -0,0 +1,20 @@
+import { useParams } from "react-router";
+import RepoTable from "@/app/components/RepoTable";
+import { useDocumentTitle } from "@/utils/useDocumentTitle";
+
+export default function RepoPage() {
+ const { org, repo } = useParams();
+ const repoName = `${org}/${repo}`;
+
+ useDocumentTitle(`${repoName} Related Repos - GitRelate(d)`);
+
+ return (
+
+
+ Related Repositories for {repoName}
+
+ {/* Keyed so that navigating to another repo starts with a fresh table */}
+
+
+ );
+}
diff --git a/app/providers.tsx b/app/providers.tsx
index db99e5d..09d86e7 100644
--- a/app/providers.tsx
+++ b/app/providers.tsx
@@ -1,19 +1,56 @@
-"use client";
-
+import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
- ThemeProvider as NextThemesProvider,
- type ThemeProviderProps,
-} from "next-themes";
+ DARK_MEDIA_QUERY,
+ THEME_STORAGE_KEY,
+ ThemeContext,
+ applyTheme,
+ getStoredTheme,
+ getSystemTheme,
+ type ResolvedTheme,
+ type Theme,
+} from "@/app/theme";
+
+export function Providers({ children }: { children: ReactNode }) {
+ const [theme, setThemeState] = useState
(getStoredTheme);
+ const [systemTheme, setSystemTheme] =
+ useState(getSystemTheme);
+ const resolvedTheme = theme === "system" ? systemTheme : theme;
+
+ useEffect(() => {
+ applyTheme(resolvedTheme);
+ }, [resolvedTheme]);
-export function Providers({ children }: ThemeProviderProps) {
- return (
-
- {children}
-
+ useEffect(() => {
+ // Follow OS theme changes and keep other tabs in sync
+ const media = window.matchMedia(DARK_MEDIA_QUERY);
+ const handleMediaChange = () => setSystemTheme(getSystemTheme());
+ const handleStorage = (e: StorageEvent) => {
+ if (e.key === THEME_STORAGE_KEY) setThemeState(getStoredTheme());
+ };
+
+ media.addEventListener("change", handleMediaChange);
+ window.addEventListener("storage", handleStorage);
+ return () => {
+ media.removeEventListener("change", handleMediaChange);
+ window.removeEventListener("storage", handleStorage);
+ };
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ theme,
+ resolvedTheme,
+ setTheme: (newTheme: Theme) => {
+ try {
+ localStorage.setItem(THEME_STORAGE_KEY, newTheme);
+ } catch {
+ // The preference just won't persist
+ }
+ setThemeState(newTheme);
+ },
+ }),
+ [theme, resolvedTheme]
);
+
+ return {children} ;
}
diff --git a/app/routes.test.tsx b/app/routes.test.tsx
new file mode 100644
index 0000000..0fd0fd6
--- /dev/null
+++ b/app/routes.test.tsx
@@ -0,0 +1,188 @@
+// @vitest-environment jsdom
+import type { RelatedRepo } from "@/types/github";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import { createMemoryRouter, RouterProvider } from "react-router";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Providers } from "@/app/providers";
+import { routes } from "@/app/routes";
+
+vi.mock("@/lib/repos.client", () => ({
+ getRelatedReposClient: vi.fn(),
+ clearExpiredCache: vi.fn(),
+}));
+
+import { getRelatedReposClient } from "@/lib/repos.client";
+
+const mockedGetRelatedReposClient = vi.mocked(getRelatedReposClient);
+
+const INVALID_URL_MESSAGE =
+ "Invalid GitHub URL. Please enter a valid GitHub repository.";
+
+function renderAt(path: string) {
+ const router = createMemoryRouter(routes, { initialEntries: [path] });
+ render(
+
+
+
+ );
+ return router;
+}
+
+function relatedRepo(repoName: string): RelatedRepo {
+ return {
+ repoName,
+ githubUrl: `https://github.com/${repoName}`,
+ stargazers: 100,
+ forkers: 50,
+ ratio: 2,
+ };
+}
+
+describe("routes", () => {
+ beforeEach(() => {
+ // Not implemented by jsdom
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({
+ matches: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }))
+ );
+ vi.stubGlobal("scrollTo", vi.fn());
+
+ // Leave the table loading unless a test provides data
+ mockedGetRelatedReposClient.mockReturnValue(new Promise(() => {}));
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ vi.resetAllMocks();
+ });
+
+ describe("home page", () => {
+ it("renders at the root path", () => {
+ renderAt("/");
+
+ expect(
+ screen.getByRole("heading", { name: "Find Related Repositories" })
+ ).toBeDefined();
+ expect(document.title).toBe(
+ "GitRelate(d) - Find Related GitHub Repositories"
+ );
+ expect(mockedGetRelatedReposClient).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("repo page", () => {
+ it("renders the repository and requests its related repos", () => {
+ renderAt("/facebook/react");
+
+ expect(
+ screen.getByRole("heading", {
+ name: "Related Repositories for facebook/react",
+ })
+ ).toBeDefined();
+ expect(document.title).toBe(
+ "facebook/react Related Repos - GitRelate(d)"
+ );
+ expect(mockedGetRelatedReposClient).toHaveBeenCalledWith(
+ "facebook/react",
+ 0
+ );
+ });
+
+ it("sets the title for different repository names", () => {
+ const testCases = [
+ "microsoft/typescript",
+ "vercel/next.js",
+ "facebook/react-native",
+ "some-org/my_repo",
+ "user/repo-with-special.chars_123",
+ "organization-with-long-name/repository-with-very-long-name-containing-multiple-words",
+ ];
+
+ for (const repoName of testCases) {
+ renderAt(`/${repoName}`);
+
+ expect(document.title).toBe(`${repoName} Related Repos - GitRelate(d)`);
+
+ cleanup();
+ }
+ });
+
+ it("lists the related repos once they load", async () => {
+ mockedGetRelatedReposClient.mockResolvedValue([
+ relatedRepo("owner/repo1"),
+ relatedRepo("owner/repo2"),
+ ]);
+
+ renderAt("/facebook/react");
+
+ expect(await screen.findByText("owner/repo1")).toBeDefined();
+ expect(screen.getByText("owner/repo2")).toBeDefined();
+ });
+
+ it("starts with a fresh table when navigating to another repo", async () => {
+ mockedGetRelatedReposClient.mockImplementation(async (repoName) => [
+ relatedRepo(`related-to/${repoName.replace("/", "-")}`),
+ ]);
+
+ const router = renderAt("/facebook/react");
+ expect(await screen.findByText("related-to/facebook-react")).toBeDefined();
+
+ await router.navigate("/vercel/next.js");
+
+ expect(await screen.findByText("related-to/vercel-next.js")).toBeDefined();
+ expect(screen.queryByText("related-to/facebook-react")).toBe(null);
+ expect(mockedGetRelatedReposClient).toHaveBeenLastCalledWith(
+ "vercel/next.js",
+ 0
+ );
+ });
+ });
+
+ describe("GitHub paths", () => {
+ it("redirects paths with extra segments to the repo page", async () => {
+ const testCases = [
+ {
+ path: "/microsoft/typescript/tree/main/src",
+ expected: "/microsoft/typescript",
+ },
+ {
+ path: "/vercel/next.js/blob/main/README.md",
+ expected: "/vercel/next.js",
+ },
+ { path: "/facebook/react/issues", expected: "/facebook/react" },
+ {
+ path: "/org/repo/tree/main/src/components/ui/button",
+ expected: "/org/repo",
+ },
+ ];
+
+ for (const { path, expected } of testCases) {
+ const router = renderAt(path);
+
+ await waitFor(() =>
+ expect(router.state.location.pathname).toBe(expected)
+ );
+ expect(
+ screen.getByRole("heading", {
+ name: `Related Repositories for ${expected.slice(1)}`,
+ })
+ ).toBeDefined();
+
+ cleanup();
+ }
+ });
+
+ it("shows an error for a path with only an organization name", () => {
+ const router = renderAt("/facebook");
+
+ expect(screen.getByText(INVALID_URL_MESSAGE)).toBeDefined();
+ expect(router.state.location.pathname).toBe("/facebook");
+ expect(mockedGetRelatedReposClient).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/routes.tsx b/app/routes.tsx
new file mode 100644
index 0000000..e3cfc63
--- /dev/null
+++ b/app/routes.tsx
@@ -0,0 +1,17 @@
+import type { RouteObject } from "react-router";
+import RootLayout from "@/app/layout";
+import GitHubPathPage from "@/app/pages/GitHubPathPage";
+import HomePage from "@/app/pages/HomePage";
+import RepoPage from "@/app/pages/RepoPage";
+
+export const routes: RouteObject[] = [
+ {
+ element: ,
+ children: [
+ { path: "/", element: },
+ { path: "/:org/:repo", element: },
+ // Anything else is treated as a path copied from github.com
+ { path: "*", element: },
+ ],
+ },
+];
diff --git a/app/theme.ts b/app/theme.ts
new file mode 100644
index 0000000..fab88b4
--- /dev/null
+++ b/app/theme.ts
@@ -0,0 +1,55 @@
+import { createContext, useContext } from "react";
+
+export type Theme = "light" | "dark" | "system";
+export type ResolvedTheme = Exclude;
+
+interface ThemeContextValue {
+ theme: Theme;
+ resolvedTheme: ResolvedTheme;
+ setTheme: (theme: Theme) => void;
+}
+
+// Keep in sync with the inline script in index.html, which applies the theme
+// before first paint
+export const THEME_STORAGE_KEY = "theme";
+export const DARK_MEDIA_QUERY = "(prefers-color-scheme: dark)";
+
+export const ThemeContext = createContext(null);
+
+export function useTheme() {
+ const context = useContext(ThemeContext);
+ if (!context) {
+ throw new Error("useTheme must be used within Providers");
+ }
+ return context;
+}
+
+export function getStoredTheme(): Theme {
+ try {
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
+ if (stored === "light" || stored === "dark") return stored;
+ } catch {
+ // Storage can be unavailable (e.g. blocked by the browser)
+ }
+ return "system";
+}
+
+export function getSystemTheme(): ResolvedTheme {
+ return window.matchMedia(DARK_MEDIA_QUERY).matches ? "dark" : "light";
+}
+
+export function applyTheme(resolvedTheme: ResolvedTheme) {
+ // Suppress CSS transitions during the swap so colors don't animate
+ const style = document.createElement("style");
+ style.textContent = "*,*::before,*::after{transition:none!important}";
+ document.head.appendChild(style);
+
+ const root = document.documentElement;
+ root.classList.remove("light", "dark");
+ root.classList.add(resolvedTheme);
+ root.style.colorScheme = resolvedTheme;
+
+ // Force a style recalculation while transitions are off, then restore them
+ window.getComputedStyle(document.body);
+ setTimeout(() => style.remove(), 1);
+}
diff --git a/app/vite-env.d.ts b/app/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/app/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/bun.lockb b/bun.lockb
index 3af79e5..67bed89 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..e89f93d
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,30 @@
+import js from "@eslint/js";
+import { defineConfig, globalIgnores } from "eslint/config";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import globals from "globals";
+import tseslint from "typescript-eslint";
+
+export default defineConfig([
+ globalIgnores(["dist", ".wrangler"]),
+ {
+ files: ["**/*.{ts,tsx}"],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactRefresh.configs.vite,
+ ],
+ plugins: {
+ "react-hooks": reactHooks,
+ },
+ languageOptions: {
+ globals: globals.browser,
+ },
+ rules: {
+ "no-unused-vars": "off",
+ "@typescript-eslint/no-explicit-any": "off",
+ "react-hooks/rules-of-hooks": "error",
+ "react-hooks/exhaustive-deps": "warn",
+ },
+ },
+]);
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..9f79017
--- /dev/null
+++ b/index.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+ GitRelate(d) - Find Related GitHub Repositories
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts
index cd050de..b45666a 100644
--- a/lib/clickhouse.ts
+++ b/lib/clickhouse.ts
@@ -102,7 +102,7 @@ export const fetchDataFromClickHouse = async (
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
- throw new Error("Request timed out. Please try again.");
+ throw new Error("Request timed out. Please try again.", { cause: error });
}
console.error("Error fetching data from ClickHouse:", error);
throw error;
diff --git a/lib/github.test.ts b/lib/github.test.ts
deleted file mode 100644
index c9683c4..0000000
--- a/lib/github.test.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { getRepo, getStargazers, getStarredRepos } from "./github";
-
-const mockFetch = vi.fn();
-global.fetch = mockFetch;
-
-describe("GitHub API functions", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- process.env.GITHUB_TOKEN = "test-token";
- });
-
- afterEach(() => {
- vi.resetAllMocks();
- });
-
- describe("getStargazers", () => {
- it("fetches stargazers successfully", async () => {
- const mockStargazers = [
- { login: "user1", avatar_url: "https://github.com/user1.png" },
- { login: "user2", avatar_url: "https://github.com/user2.png" },
- ];
-
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: async () => mockStargazers,
- });
-
- const result = await getStargazers("owner/repo");
-
- expect(mockFetch).toHaveBeenCalledWith(
- "https://api.github.com/repos/owner/repo/stargazers",
- {
- headers: {
- Accept: "application/vnd.github.v3.star+json",
- Authorization: "token test-token",
- },
- }
- );
- expect(result).toEqual(mockStargazers);
- });
-
- it("returns empty array when response is not ok", async () => {
- mockFetch.mockResolvedValueOnce({
- ok: false,
- status: 404,
- });
-
- const result = await getStargazers("owner/repo");
-
- expect(result).toEqual([]);
- });
-
- it("handles network errors gracefully", async () => {
- mockFetch.mockRejectedValueOnce(new Error("Network error"));
-
- await expect(getStargazers("owner/repo")).rejects.toThrow(
- "Network error"
- );
- });
- });
-
- describe("getStarredRepos", () => {
- it("fetches starred repos successfully", async () => {
- const mockStarredRepos = [
- {
- id: 1,
- name: "repo1",
- full_name: "owner/repo1",
- description: "A test repo",
- stargazers_count: 100,
- },
- ];
-
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: async () => mockStarredRepos,
- });
-
- const result = await getStarredRepos("testuser");
-
- expect(mockFetch).toHaveBeenCalledWith(
- "https://api.github.com/users/testuser/starred",
- {
- headers: {
- Accept: "application/vnd.github.v3.star+json",
- Authorization: "token test-token",
- },
- }
- );
- expect(result).toEqual(mockStarredRepos);
- });
-
- it("returns empty array when response is not ok", async () => {
- mockFetch.mockResolvedValueOnce({
- ok: false,
- status: 404,
- });
-
- const result = await getStarredRepos("testuser");
-
- expect(result).toEqual([]);
- });
- });
-
- describe("getRepo", () => {
- it("fetches repo successfully", async () => {
- const mockRepo = {
- id: 1,
- name: "repo",
- full_name: "owner/repo",
- description: "A test repo",
- stargazers_count: 100,
- };
-
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: async () => mockRepo,
- });
-
- const result = await getRepo("owner/repo");
-
- expect(mockFetch).toHaveBeenCalledWith(
- "https://api.github.com/repos/owner/repo",
- {
- headers: {
- Accept: "application/vnd.github.v3.star+json",
- Authorization: "token test-token",
- },
- }
- );
- expect(result).toEqual(mockRepo);
- });
-
- it("returns empty array when response is not ok", async () => {
- mockFetch.mockResolvedValueOnce({
- ok: false,
- status: 404,
- });
-
- const result = await getRepo("owner/repo");
-
- expect(result).toEqual([]);
- });
- });
-
- describe("Authorization header", () => {
- it("includes token from environment variable", async () => {
- process.env.GITHUB_TOKEN = "custom-token";
-
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: async () => [],
- });
-
- await getStargazers("owner/repo");
-
- expect(mockFetch).toHaveBeenCalledWith(
- expect.any(String),
- expect.objectContaining({
- headers: expect.objectContaining({
- Authorization: "token custom-token",
- }),
- })
- );
- });
-
- it("handles missing token gracefully", async () => {
- delete process.env.GITHUB_TOKEN;
-
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: async () => [],
- });
-
- await getStargazers("owner/repo");
-
- expect(mockFetch).toHaveBeenCalledWith(
- expect.any(String),
- expect.objectContaining({
- headers: expect.objectContaining({
- Authorization: "token undefined",
- }),
- })
- );
- });
- });
-});
diff --git a/lib/github.ts b/lib/github.ts
deleted file mode 100644
index 9924647..0000000
--- a/lib/github.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-const GITHUB_API = "https://api.github.com";
-
-export async function getStargazers(repo: string) {
- const response = await fetch(`${GITHUB_API}/repos/${repo}/stargazers`, {
- headers: {
- Accept: "application/vnd.github.v3.star+json",
- Authorization: `token ${process.env.GITHUB_TOKEN}`,
- },
- });
- return response.ok ? response.json() : [];
-}
-
-export async function getStarredRepos(username: string) {
- const response = await fetch(`${GITHUB_API}/users/${username}/starred`, {
- headers: {
- Accept: "application/vnd.github.v3.star+json",
- Authorization: `token ${process.env.GITHUB_TOKEN}`,
- },
- });
- return response.ok ? response.json() : [];
-}
-
-export async function getRepo(repo: string) {
- const response = await fetch(`${GITHUB_API}/repos/${repo}`, {
- headers: {
- Accept: "application/vnd.github.v3.star+json",
- Authorization: `token ${process.env.GITHUB_TOKEN}`,
- },
- });
- return response.ok ? response.json() : [];
-}
diff --git a/lib/repos.client.test.ts b/lib/repos.client.test.ts
new file mode 100644
index 0000000..d329b6d
--- /dev/null
+++ b/lib/repos.client.test.ts
@@ -0,0 +1,169 @@
+// @vitest-environment jsdom
+import type { RelatedRepo } from "@/types/github";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { clearExpiredCache, getRelatedReposClient } from "./repos.client";
+
+vi.mock("@/lib/clickhouse", () => ({
+ buildQuery: vi.fn(),
+ fetchDataFromClickHouse: vi.fn(),
+}));
+
+import { buildQuery, fetchDataFromClickHouse } from "@/lib/clickhouse";
+
+const mockBuildQuery = vi.mocked(buildQuery);
+const mockFetchDataFromClickHouse = vi.mocked(fetchDataFromClickHouse);
+
+const ONE_DAY_MS = 24 * 60 * 60 * 1000;
+
+describe("client repos utilities", () => {
+ const mockQuery = "SELECT * FROM github_events WHERE repo_name = 'test/repo'";
+ const mockRelatedRepos: RelatedRepo[] = [
+ {
+ repoName: "owner/repo1",
+ githubUrl: "https://github.com/owner/repo1",
+ stargazers: 100,
+ forkers: 50,
+ ratio: 2.0,
+ },
+ {
+ repoName: "owner/repo2",
+ githubUrl: "https://github.com/owner/repo2",
+ stargazers: 200,
+ forkers: 75,
+ ratio: 2.67,
+ },
+ ];
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ vi.spyOn(console, "log").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ vi.resetAllMocks();
+ });
+
+ describe("getRelatedReposClient", () => {
+ it("fetches related repos and caches them", async () => {
+ mockBuildQuery.mockReturnValue(mockQuery);
+ mockFetchDataFromClickHouse.mockResolvedValue(mockRelatedRepos);
+
+ const result = await getRelatedReposClient("test/repo", 0);
+
+ expect(mockBuildQuery).toHaveBeenCalledWith(
+ "test/repo",
+ 100,
+ "stargazers",
+ 0,
+ 0,
+ 0,
+ 0
+ );
+ expect(mockFetchDataFromClickHouse).toHaveBeenCalledWith(mockQuery);
+ expect(result).toEqual(mockRelatedRepos);
+
+ const cached = JSON.parse(
+ localStorage.getItem("gitrelate_repos_test/repo_0")!
+ );
+ expect(cached.data).toEqual(mockRelatedRepos);
+ expect(cached.expiresAt - cached.timestamp).toBe(ONE_DAY_MS);
+ });
+
+ it("returns cached data without querying ClickHouse again", async () => {
+ mockBuildQuery.mockReturnValue(mockQuery);
+ mockFetchDataFromClickHouse.mockResolvedValue(mockRelatedRepos);
+
+ await getRelatedReposClient("test/repo", 0);
+ const result = await getRelatedReposClient("test/repo", 0);
+
+ expect(mockFetchDataFromClickHouse).toHaveBeenCalledTimes(1);
+ expect(result).toEqual(mockRelatedRepos);
+ });
+
+ it("uses offset parameter correctly", async () => {
+ mockBuildQuery.mockReturnValue(mockQuery);
+ mockFetchDataFromClickHouse.mockResolvedValue(mockRelatedRepos);
+
+ await getRelatedReposClient("test/repo", 0);
+ await getRelatedReposClient("test/repo", 100);
+
+ expect(mockBuildQuery).toHaveBeenLastCalledWith(
+ "test/repo",
+ 100,
+ "stargazers",
+ 0,
+ 0,
+ 0,
+ 100
+ );
+ // Each offset is cached separately
+ expect(mockFetchDataFromClickHouse).toHaveBeenCalledTimes(2);
+ expect(localStorage.getItem("gitrelate_repos_test/repo_100")).not.toBe(
+ null
+ );
+ });
+
+ it("refetches once the cached entry has expired", async () => {
+ vi.useFakeTimers();
+ mockBuildQuery.mockReturnValue(mockQuery);
+ mockFetchDataFromClickHouse.mockResolvedValue(mockRelatedRepos);
+
+ await getRelatedReposClient("test/repo", 0);
+ vi.advanceTimersByTime(ONE_DAY_MS + 1);
+ await getRelatedReposClient("test/repo", 0);
+
+ expect(mockFetchDataFromClickHouse).toHaveBeenCalledTimes(2);
+ });
+
+ it("throws error when buildQuery returns null", async () => {
+ mockBuildQuery.mockReturnValue(null);
+
+ await expect(getRelatedReposClient("invalid-repo", 0)).rejects.toThrow(
+ "Invalid repository name"
+ );
+
+ expect(mockFetchDataFromClickHouse).not.toHaveBeenCalled();
+ });
+
+ it("propagates errors from fetchDataFromClickHouse without caching", async () => {
+ mockBuildQuery.mockReturnValue(mockQuery);
+ mockFetchDataFromClickHouse.mockRejectedValue(
+ new Error("Network timeout")
+ );
+
+ await expect(getRelatedReposClient("test/repo", 0)).rejects.toThrow(
+ "Network timeout"
+ );
+
+ expect(localStorage.length).toBe(0);
+ });
+ });
+
+ describe("clearExpiredCache", () => {
+ it("removes expired and invalid entries but keeps everything else", () => {
+ const now = Date.now();
+ localStorage.setItem(
+ "gitrelate_repos_fresh/repo_0",
+ JSON.stringify({ data: [], timestamp: now, expiresAt: now + 1000 })
+ );
+ localStorage.setItem(
+ "gitrelate_repos_expired/repo_0",
+ JSON.stringify({ data: [], timestamp: now - 2000, expiresAt: now - 1 })
+ );
+ localStorage.setItem("gitrelate_repos_invalid/repo_0", "not json");
+ localStorage.setItem("theme", "dark");
+
+ clearExpiredCache();
+
+ expect(localStorage.getItem("gitrelate_repos_fresh/repo_0")).not.toBe(
+ null
+ );
+ expect(localStorage.getItem("gitrelate_repos_expired/repo_0")).toBe(null);
+ expect(localStorage.getItem("gitrelate_repos_invalid/repo_0")).toBe(null);
+ expect(localStorage.getItem("theme")).toBe("dark");
+ });
+ });
+});
diff --git a/lib/repos.test.ts b/lib/repos.test.ts
deleted file mode 100644
index 2bd7aa5..0000000
--- a/lib/repos.test.ts
+++ /dev/null
@@ -1,228 +0,0 @@
-import type { RelatedRepo } from "@/types/github";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { getRelatedReposCached, getRelatedReposUncached } from "./repos";
-
-vi.mock("@/lib/clickhouse", () => ({
- buildQuery: vi.fn(),
- fetchDataFromClickHouse: vi.fn(),
-}));
-
-vi.mock("next/cache", () => ({
- unstable_cache: vi.fn((fn) => {
- // Return a function that just calls the original function for testing
- return () => fn();
- }),
-}));
-
-import { buildQuery, fetchDataFromClickHouse } from "@/lib/clickhouse";
-import { unstable_cache } from "next/cache";
-
-const mockBuildQuery = vi.mocked(buildQuery);
-const mockFetchDataFromClickHouse = vi.mocked(fetchDataFromClickHouse);
-const mockUnstableCache = vi.mocked(unstable_cache);
-
-describe("repos utilities", () => {
- const mockRelatedRepos: RelatedRepo[] = [
- {
- repoName: "owner/repo1",
- githubUrl: "https://github.com/owner/repo1",
- stargazers: 100,
- forkers: 50,
- ratio: 2.0,
- },
- {
- repoName: "owner/repo2",
- githubUrl: "https://github.com/owner/repo2",
- stargazers: 200,
- forkers: 75,
- ratio: 2.67,
- },
- ];
-
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- afterEach(() => {
- vi.resetAllMocks();
- });
-
- describe("getRelatedReposUncached", () => {
- it("fetches related repos successfully", async () => {
- const mockQuery =
- "SELECT * FROM github_events WHERE repo_name = 'test/repo'";
- mockBuildQuery.mockReturnValue(mockQuery);
- mockFetchDataFromClickHouse.mockResolvedValue(mockRelatedRepos);
-
- const result = await getRelatedReposUncached("test/repo");
-
- expect(mockBuildQuery).toHaveBeenCalledWith(
- "test/repo",
- 100,
- "stargazers",
- 0,
- 0,
- 0
- );
- expect(mockFetchDataFromClickHouse).toHaveBeenCalledWith(mockQuery);
- expect(result).toEqual(mockRelatedRepos);
- });
-
- it("throws error when buildQuery returns null", async () => {
- mockBuildQuery.mockReturnValue(null);
-
- await expect(getRelatedReposUncached("invalid-repo")).rejects.toThrow(
- "Invalid repository name"
- );
-
- expect(mockBuildQuery).toHaveBeenCalledWith(
- "invalid-repo",
- 100,
- "stargazers",
- 0,
- 0,
- 0
- );
- expect(mockFetchDataFromClickHouse).not.toHaveBeenCalled();
- });
-
- it("propagates errors from fetchDataFromClickHouse", async () => {
- const mockQuery =
- "SELECT * FROM github_events WHERE repo_name = 'test/repo'";
- mockBuildQuery.mockReturnValue(mockQuery);
- mockFetchDataFromClickHouse.mockRejectedValue(
- new Error("ClickHouse error")
- );
-
- await expect(getRelatedReposUncached("test/repo")).rejects.toThrow(
- "ClickHouse error"
- );
-
- expect(mockBuildQuery).toHaveBeenCalledWith(
- "test/repo",
- 100,
- "stargazers",
- 0,
- 0,
- 0
- );
- expect(mockFetchDataFromClickHouse).toHaveBeenCalledWith(mockQuery);
- });
- });
-
- describe("getRelatedReposCached", () => {
- it("fetches related repos with caching", async () => {
- const mockQuery =
- "SELECT * FROM github_events WHERE repo_name = 'test/repo'";
- mockBuildQuery.mockReturnValue(mockQuery);
- mockFetchDataFromClickHouse.mockResolvedValue(mockRelatedRepos);
-
- const result = await getRelatedReposCached("test/repo", 0);
-
- expect(mockUnstableCache).toHaveBeenCalledWith(
- expect.any(Function),
- ["related-repos", "test/repo", "0"],
- { revalidate: 86400 } // ONE_DAY = 60 * 60 * 24
- );
-
- expect(mockBuildQuery).toHaveBeenCalledWith(
- "test/repo",
- 100,
- "stargazers",
- 0,
- 0,
- 0,
- 0
- );
- expect(mockFetchDataFromClickHouse).toHaveBeenCalledWith(mockQuery);
- expect(result).toEqual(mockRelatedRepos);
- });
-
- it("uses offset parameter correctly", async () => {
- const mockQuery =
- "SELECT * FROM github_events WHERE repo_name = 'test/repo' OFFSET 50";
- mockBuildQuery.mockReturnValue(mockQuery);
- mockFetchDataFromClickHouse.mockResolvedValue(mockRelatedRepos);
-
- await getRelatedReposCached("test/repo", 50);
-
- expect(mockUnstableCache).toHaveBeenCalledWith(
- expect.any(Function),
- ["related-repos", "test/repo", "50"],
- { revalidate: 86400 }
- );
-
- expect(mockBuildQuery).toHaveBeenCalledWith(
- "test/repo",
- 100,
- "stargazers",
- 0,
- 0,
- 0,
- 50
- );
- });
-
- it("throws error when buildQuery returns null in cached version", async () => {
- mockBuildQuery.mockReturnValue(null);
-
- await expect(getRelatedReposCached("invalid-repo", 0)).rejects.toThrow(
- "Invalid repository name"
- );
-
- expect(mockBuildQuery).toHaveBeenCalledWith(
- "invalid-repo",
- 100,
- "stargazers",
- 0,
- 0,
- 0,
- 0
- );
- expect(mockFetchDataFromClickHouse).not.toHaveBeenCalled();
- });
-
- it("propagates errors from fetchDataFromClickHouse in cached version", async () => {
- const mockQuery =
- "SELECT * FROM github_events WHERE repo_name = 'test/repo'";
- mockBuildQuery.mockReturnValue(mockQuery);
- mockFetchDataFromClickHouse.mockRejectedValue(
- new Error("Network timeout")
- );
-
- await expect(getRelatedReposCached("test/repo", 0)).rejects.toThrow(
- "Network timeout"
- );
- });
-
- it("creates cache key with different repo names correctly", async () => {
- const mockQuery = "SELECT * FROM github_events";
- mockBuildQuery.mockReturnValue(mockQuery);
- mockFetchDataFromClickHouse.mockResolvedValue([]);
-
- await getRelatedReposCached("facebook/react", 25);
-
- expect(mockUnstableCache).toHaveBeenCalledWith(
- expect.any(Function),
- ["related-repos", "facebook/react", "25"],
- { revalidate: 86400 }
- );
- });
- });
-
- describe("cache configuration", () => {
- it("sets correct cache revalidation time", async () => {
- const mockQuery = "SELECT * FROM test";
- mockBuildQuery.mockReturnValue(mockQuery);
- mockFetchDataFromClickHouse.mockResolvedValue([]);
-
- await getRelatedReposCached("test/repo", 0);
-
- expect(mockUnstableCache).toHaveBeenCalledWith(
- expect.any(Function),
- expect.any(Array),
- { revalidate: 86400 } // 24 hours in seconds
- );
- });
- });
-});
diff --git a/lib/repos.ts b/lib/repos.ts
deleted file mode 100644
index 4da42f0..0000000
--- a/lib/repos.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { buildQuery, fetchDataFromClickHouse } from "@/lib/clickhouse";
-import { unstable_cache } from "next/cache";
-
-const ONE_DAY = 60 * 60 * 24;
-
-export function getRelatedReposCached(repoName: string, offset: number) {
- return unstable_cache(
- async () => {
- const query = buildQuery(repoName, 100, "stargazers", 0, 0, 0, offset);
- if (!query) {
- throw new Error("Invalid repository name");
- }
- return await fetchDataFromClickHouse(query);
- },
- ["related-repos", repoName, offset.toString()],
- { revalidate: ONE_DAY }
- )();
-}
-
-export const getRelatedReposUncached = async (repoName: string) => {
- const query = buildQuery(repoName, 100, "stargazers", 0, 0, 0);
- if (!query) {
- throw new Error("Invalid repository name");
- }
- return await fetchDataFromClickHouse(query);
-};
diff --git a/next.config.ts b/next.config.ts
deleted file mode 100644
index 825f329..0000000
--- a/next.config.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { NextConfig } from "next";
-
-const nextConfig: NextConfig = {
- experimental: {
- // Disable prefetching on hover
- optimisticClientCache: false,
- },
- // Add headers to discourage bots and crawlers
- async headers() {
- return [
- {
- source: "/(.*)",
- headers: [
- {
- key: "X-Robots-Tag",
- value: "noindex, nofollow, nosnippet, noarchive, noimageindex",
- },
- {
- key: "Cache-Control",
- value: "public, max-age=86400, s-maxage=86400", // Cache for 24 hours
- },
- ],
- },
- ];
- },
-};
-
-export default nextConfig;
diff --git a/package.json b/package.json
index 1d4b20d..f2e3eeb 100644
--- a/package.json
+++ b/package.json
@@ -2,32 +2,43 @@
"name": "gitrelate",
"version": "0.1.0",
"private": true,
+ "type": "module",
"scripts": {
- "dev": "next dev --turbopack",
- "build": "next build",
- "start": "next start",
- "lint": "next lint",
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "bun run build && wrangler dev",
+ "deploy": "bun run build && wrangler deploy",
+ "lint": "eslint .",
"test": "vitest --run"
},
"dependencies": {
"@headlessui/react": "^2.2.4",
"clsx": "^2.1.1",
"lucide-react": "^0.512.0",
- "next": "15.4.10",
- "next-themes": "^0.4.6",
- "react": "19.2.0-canary-f9ae0a4c-20250527",
- "react-dom": "19.2.0-canary-f9ae0a4c-20250527",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "react-router": "^8.4.0",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@testing-library/dom": "^10.4.2",
+ "@testing-library/react": "^16.3.3",
"@types/node": "^22",
- "@types/react": "^19",
- "@types/react-dom": "^19",
- "eslint": "^8",
- "eslint-config-next": "15.3.3",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.1",
+ "eslint": "^10.12.0",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.7",
+ "globals": "^17.13.0",
+ "jsdom": "^29.1.1",
"postcss": "^8.5.4",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
- "vitest": "^3.2.1"
+ "typescript-eslint": "^8.71.0",
+ "vite": "^8.3.2",
+ "vitest": "^5.0.3",
+ "wrangler": "^4.147.0"
}
}
diff --git a/public/_headers b/public/_headers
new file mode 100644
index 0000000..07e0f7c
--- /dev/null
+++ b/public/_headers
@@ -0,0 +1,10 @@
+# Cloudflare static asset headers
+# https://developers.cloudflare.com/workers/static-assets/headers/
+
+# Discourage bots and crawlers
+/*
+ X-Robots-Tag: noindex, nofollow, nosnippet, noarchive, noimageindex
+
+# Vite content-hashes these filenames, so they can be cached indefinitely
+/_assets/*
+ Cache-Control: public, max-age=31536000, immutable
diff --git a/tailwind.config.ts b/tailwind.config.ts
index e868436..1b6dbc2 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -2,11 +2,7 @@ import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
- content: [
- "./pages/**/*.{js,ts,jsx,tsx,mdx}",
- "./components/**/*.{js,ts,jsx,tsx,mdx}",
- "./app/**/*.{js,ts,jsx,tsx,mdx}",
- ],
+ content: ["./index.html", "./app/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {
colors: {
diff --git a/tsconfig.json b/tsconfig.json
index d8b9323..9379c49 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,17 +11,12 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
"paths": {
"@/*": ["./*"]
}
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
+ "include": ["**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules", "dist"]
}
diff --git a/utils/useDocumentTitle.ts b/utils/useDocumentTitle.ts
new file mode 100644
index 0000000..64d5dd1
--- /dev/null
+++ b/utils/useDocumentTitle.ts
@@ -0,0 +1,11 @@
+import { useEffect } from "react";
+
+/**
+ * Sets the browser tab title while the calling component is rendered.
+ * @param {string} title - The document title to apply.
+ */
+export function useDocumentTitle(title: string) {
+ useEffect(() => {
+ document.title = title;
+ }, [title]);
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..3696511
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,20 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": import.meta.dirname,
+ },
+ },
+ build: {
+ // GitHub usernames can't start with an underscore, so built assets can
+ // never shadow an /org/repo route
+ assetsDir: "_assets",
+ },
+ test: {
+ globals: true,
+ environment: "node",
+ },
+});
diff --git a/vitest.config.ts b/vitest.config.ts
deleted file mode 100644
index c89a0e6..0000000
--- a/vitest.config.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import path from "path";
-import { defineConfig } from "vitest/config";
-
-export default defineConfig({
- test: {
- globals: true,
- environment: "node",
- },
- resolve: {
- alias: {
- "@": path.resolve(__dirname, "."),
- },
- },
-});
diff --git a/wrangler.jsonc b/wrangler.jsonc
new file mode 100644
index 0000000..97e6820
--- /dev/null
+++ b/wrangler.jsonc
@@ -0,0 +1,23 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "gitrelate",
+ "compatibility_date": "2026-10-04",
+ // Assets-only Worker: with no Worker script, every request is served as a
+ // static asset, which Cloudflare does not bill or rate limit
+ "assets": {
+ "directory": "./dist",
+ // Serve index.html for /org/repo paths so the client-side router handles them
+ "not_found_handling": "single-page-application"
+ },
+ // Each route needs its zone to be active in the same Cloudflare account, with
+ // a proxied DNS record for the hostname. The record's target is never
+ // contacted because the Worker answers every request.
+ "routes": [
+ { "pattern": "gitrelated.com/*", "zone_name": "gitrelated.com" },
+ { "pattern": "www.gitrelated.com/*", "zone_name": "gitrelated.com" },
+ { "pattern": "gitrelate.com/*", "zone_name": "gitrelate.com" },
+ { "pattern": "www.gitrelate.com/*", "zone_name": "gitrelate.com" }
+ ],
+ // Keep the workers.dev URL as a way to check a deploy independently of DNS
+ "workers_dev": true
+}