Skip to content

Commit 5e35d2e

Browse files
committed
feat: dynamically list workbench repositories
1 parent 3e61f09 commit 5e35d2e

7 files changed

Lines changed: 153 additions & 69 deletions

File tree

README.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# jonathanperis.github.io
22

3-
> Personal developer portfolio built with Next.js — dynamically fetches GitHub projects, dark terminal aesthetic, print-optimized resume
3+
> Personal developer portfolio built with Astro — dynamically fetches GitHub projects, dark terminal aesthetic, print-optimized resume
44
55
[![Build Check](https://github.com/jonathanperis/jonathanperis.github.io/actions/workflows/build-check.yml/badge.svg)](https://github.com/jonathanperis/jonathanperis.github.io/actions/workflows/build-check.yml) [![Main Release](https://github.com/jonathanperis/jonathanperis.github.io/actions/workflows/main-release.yml/badge.svg)](https://github.com/jonathanperis/jonathanperis.github.io/actions/workflows/main-release.yml) [![CodeQL](https://github.com/jonathanperis/jonathanperis.github.io/actions/workflows/codeql.yml/badge.svg)](https://github.com/jonathanperis/jonathanperis.github.io/actions/workflows/codeql.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
66

@@ -10,7 +10,7 @@
1010

1111
## About
1212

13-
Next.js 16 App Router portfolio with a static export for GitHub Pages. It fetches pinned repositories from the GitHub GraphQL API at build time and renders them in a terminal-themed UI.
13+
Astro portfolio with a static export for GitHub Pages. It fetches public, non-fork repositories from the GitHub GraphQL API at build time, resolves live GitHub Pages links through the REST API, and renders them in a terminal-themed UI.
1414

1515
The site includes a print-optimized resume page, SEO metadata, analytics, and a Konami code easter egg. The same shared data powers the on-page resume and the dedicated `/resume` route.
1616

@@ -20,16 +20,17 @@ It is built to stay simple to deploy: build locally, export statically, and publ
2020

2121
| Technology | Version | Purpose |
2222
|-----------|---------|---------|
23-
| Next.js | 16 | App Router site with static export |
24-
| React | 19 | UI rendering |
23+
| Astro | 6 | Static site build and GitHub Pages export |
24+
| React | 19 | Interactive portfolio UI |
2525
| TypeScript | Latest | Type safety |
2626
| Tailwind CSS | v4 | Styling system |
27-
| GitHub GraphQL API | v4 | Fetches pinned repos at build time |
27+
| GitHub GraphQL + REST APIs | v4 / REST | Fetches repositories and live Pages URLs at build time |
2828
| Google Analytics 4 | GA4 | Traffic and engagement analytics |
2929

3030
## Features
3131

32-
- Dynamic projects from GitHub GraphQL API (pinned repos)
32+
- Dynamic Workbench repository ledger from GitHub GraphQL API (public, non-fork repos)
33+
- Live GitHub Pages links resolved at build time via GitHub REST API
3334
- Terminal-themed dark UI with typing animations and scroll effects
3435
- Print-optimized resume page with download support
3536
- PWA manifest and SEO optimizations
@@ -40,18 +41,18 @@ It is built to stay simple to deploy: build locally, export statically, and publ
4041

4142
### Prerequisites
4243

43-
- Node.js 18+, npm
44+
- Node.js 22+, Bun
4445

4546
### Quick Start
4647

4748
```bash
4849
git clone https://github.com/jonathanperis/jonathanperis.github.io.git
4950
cd jonathanperis.github.io
50-
npm install
51-
npm run dev
51+
bun install
52+
bun run dev
5253
```
5354

54-
Open http://localhost:3000
55+
Open http://localhost:4321
5556

5657
## CI/CD
5758

src/components/Portfolio.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ function projectLane(project: (typeof FEATURED_PROJECTS)[number]) {
162162
export default function Portfolio({ projects }: { projects: GitHubRepo[] }) {
163163
const scrollProgress = useScrollProgress();
164164
const featuredSlugs = useMemo(() => new Set(FEATURED_PROJECTS.map((fp) => fp.slug)), []);
165+
const workbenchRepos = useMemo(() => projects.filter((project) => !featuredSlugs.has(project.title)), [featuredSlugs, projects]);
165166

166167
const [termOpen, setTermOpen] = useState(false);
167168
const [termInput, setTermInput] = useState("");
@@ -395,28 +396,31 @@ export default function Portfolio({ projects }: { projects: GitHubRepo[] }) {
395396
</Reveal>
396397
))}
397398
</div>
398-
</section>
399399

400-
<section className="content-section" aria-labelledby="repos-heading">
401-
<Reveal>
402-
<SectionLabel id="repos-heading" number="05">Repository tail</SectionLabel>
400+
<Reveal delay={320}>
401+
<div className="repo-ledger-heading">
402+
<p className="font-mono text-[10px] uppercase tracking-[0.28em] text-dim">public repository ledger</p>
403+
<h3>All non-fork GitHub work</h3>
404+
<p>Fetched at build time from GitHub, excluding this portfolio, profile metadata, collaborator repos, and forks. Pages links resolve from each repository's live GitHub Pages site.</p>
405+
</div>
403406
</Reveal>
404407
<div className="repo-table" role="list">
405-
{projects.filter((p) => !featuredSlugs.has(p.title) && p.title !== ".github").slice(0, 8).map((project, index) => (
408+
{workbenchRepos.map((project, index) => (
406409
<Reveal key={project.title} delay={index * 35}>
407410
<article role="listitem" className="repo-row">
408411
<a href={project.url} target="_blank" rel="noreferrer noopener">{project.title}</a>
409412
<p>{project.description || "Repository note pending."}</p>
410413
<div>
411414
{project.stars > 0 && <span>{project.stars} stars</span>}
412415
{project.lang && <span>{project.lang}</span>}
413-
{project.homepageUrl && <a href={project.homepageUrl} target="_blank" rel="noreferrer noopener">site</a>}
416+
{project.pagesUrl && <a href={project.pagesUrl} target="_blank" rel="noreferrer noopener">GitHub Pages</a>}
417+
{project.homepageUrl && project.homepageUrl !== project.pagesUrl && <a href={project.homepageUrl} target="_blank" rel="noreferrer noopener">homepage</a>}
414418
</div>
415419
</article>
416420
</Reveal>
417421
))}
418422
</div>
419-
<Reveal delay={320}>
423+
<Reveal delay={workbenchRepos.length * 35 + 120}>
420424
<a href="https://github.com/jonathanperis" target="_blank" rel="noreferrer noopener" className="github-tail">View all repositories on GitHub</a>
421425
</Reveal>
422426
</section>

src/lib/github.ts

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,35 @@ export type GitHubRepo = {
66
langColor: string;
77
stars: number;
88
homepageUrl?: string;
9+
pagesUrl?: string;
10+
updatedAt?: string;
911
};
1012

11-
const EXCLUDE_REPOS = ["jonathanperis.github.io", "jonathanperis"];
13+
const GITHUB_OWNER = "jonathanperis";
14+
const EXCLUDE_REPOS = new Set(["jonathanperis.github.io", ".github", "jonathanperis"]);
1215

1316
const FALLBACK: GitHubRepo[] = [
14-
{ title: "cpnucleo", description: "Modern .NET sample — clean architecture, testing, DI, and Docker containerization.", url: "https://github.com/jonathanperis/cpnucleo", lang: "C#", langColor: "#178600", stars: 8, homepageUrl: "https://jonathanperis.github.io/cpnucleo/" },
15-
{ title: "super-mango-editor", description: "A classic side-scrolling platformer built with C and SDL2 — playable in the browser via WebAssembly.", url: "https://github.com/jonathanperis/super-mango-editor", lang: "C", langColor: "#555555", stars: 0, homepageUrl: "https://jonathanperis.github.io/super-mango-editor/" },
16-
{ title: "rinha2-back-end-dotnet", description: "High-performance Rinha de Backend challenge in C# with PostgreSQL and Nginx.", url: "https://github.com/jonathanperis/rinha2-back-end-dotnet", lang: "C#", langColor: "#178600", stars: 3, homepageUrl: "https://jonathanperis.github.io/rinha2-back-end-dotnet/" },
17-
{ title: "rinha2-back-end-k6", description: "K6 load testing suite for the Rinha de Backend challenge.", url: "https://github.com/jonathanperis/rinha2-back-end-k6", lang: "JavaScript", langColor: "#f1e05a", stars: 0, homepageUrl: "https://jonathanperis.github.io/rinha2-back-end-k6/" },
18-
{ title: "blazor-mudblazor-starter", description: "Blazor + MudBlazor starter template with pre-configured components.", url: "https://github.com/jonathanperis/blazor-mudblazor-starter", lang: "C#", langColor: "#178600", stars: 1, homepageUrl: "https://jonathanperis.github.io/blazor-mudblazor-starter/" },
19-
{ title: "rinha2-back-end-go", description: "Rinha de Backend in Go — high-performance with PostgreSQL and Nginx.", url: "https://github.com/jonathanperis/rinha2-back-end-go", lang: "Go", langColor: "#00ADD8", stars: 1 },
17+
{ title: "cpnucleo", description: "Modern .NET sample — clean architecture, testing, DI, and Docker containerization.", url: "https://github.com/jonathanperis/cpnucleo", lang: "C#", langColor: "#178600", stars: 8, homepageUrl: "https://jonathanperis.github.io/cpnucleo/", pagesUrl: "https://jonathanperis.github.io/cpnucleo/" },
18+
{ title: "super-mango-editor", description: "A classic side-scrolling platformer built with C and SDL2 — playable in the browser via WebAssembly.", url: "https://github.com/jonathanperis/super-mango-editor", lang: "C", langColor: "#555555", stars: 0, homepageUrl: "https://jonathanperis.github.io/super-mango-editor/", pagesUrl: "https://jonathanperis.github.io/super-mango-editor/" },
19+
{ title: "rinha4-back-end-dotnet", description: "Rinha de Backend 2025 implementation in .NET with docs and benchmark reports.", url: "https://github.com/jonathanperis/rinha4-back-end-dotnet", lang: "C#", langColor: "#178600", stars: 0, homepageUrl: "https://jonathanperis.github.io/rinha4-back-end-dotnet/", pagesUrl: "https://jonathanperis.github.io/rinha4-back-end-dotnet/" },
20+
{ title: "rinha2-back-end-dotnet", description: "High-performance Rinha de Backend challenge in C# with PostgreSQL and Nginx.", url: "https://github.com/jonathanperis/rinha2-back-end-dotnet", lang: "C#", langColor: "#178600", stars: 3, homepageUrl: "https://jonathanperis.github.io/rinha2-back-end-dotnet/", pagesUrl: "https://jonathanperis.github.io/rinha2-back-end-dotnet/" },
21+
{ title: "rinha2-back-end-k6", description: "K6 load testing suite for the Rinha de Backend challenge.", url: "https://github.com/jonathanperis/rinha2-back-end-k6", lang: "JavaScript", langColor: "#f1e05a", stars: 0, homepageUrl: "https://jonathanperis.github.io/rinha2-back-end-k6/", pagesUrl: "https://jonathanperis.github.io/rinha2-back-end-k6/" },
22+
{ title: "blazor-mudblazor-starter", description: "Blazor + MudBlazor starter template with pre-configured components.", url: "https://github.com/jonathanperis/blazor-mudblazor-starter", lang: "HTML", langColor: "#e34c26", stars: 1, homepageUrl: "https://jonathanperis.github.io/blazor-mudblazor-starter/", pagesUrl: "https://jonathanperis.github.io/blazor-mudblazor-starter/" },
23+
{ title: "rinha4-back-end-c", description: "Rinha de Backend 2025 C implementation with GitHub Pages documentation.", url: "https://github.com/jonathanperis/rinha4-back-end-c", lang: "C", langColor: "#555555", stars: 0, homepageUrl: "https://jonathanperis.github.io/rinha4-back-end-c/", pagesUrl: "https://jonathanperis.github.io/rinha4-back-end-c/" },
24+
{ title: "rinha2-back-end-go", description: "Rinha de Backend in Go — high-performance with PostgreSQL and Nginx.", url: "https://github.com/jonathanperis/rinha2-back-end-go", lang: "PLpgSQL", langColor: "#336790", stars: 1, homepageUrl: "https://jonathanperis.github.io/rinha2-back-end-go/", pagesUrl: "https://jonathanperis.github.io/rinha2-back-end-go/" },
2025
];
2126

2227
const QUERY = `{
23-
user(login: "jonathanperis") {
28+
user(login: "${GITHUB_OWNER}") {
2429
repositories(first: 100, privacy: PUBLIC, orderBy: { field: UPDATED_AT, direction: DESC }, isFork: false) {
2530
nodes {
2631
name
2732
description
2833
url
2934
homepageUrl
3035
stargazerCount
36+
updatedAt
37+
owner { login }
3138
primaryLanguage { name color }
3239
}
3340
}
@@ -36,13 +43,63 @@ const QUERY = `{
3643

3744
type RepoNode = {
3845
name: string;
39-
description: string;
46+
description: string | null;
4047
url: string;
41-
homepageUrl?: string;
48+
homepageUrl?: string | null;
4249
stargazerCount: number;
50+
updatedAt?: string;
51+
owner: { login: string };
4352
primaryLanguage: { name: string; color: string } | null;
4453
};
4554

55+
type PagesResponse = {
56+
html_url?: string;
57+
};
58+
59+
function buildHeaders(token: string) {
60+
return {
61+
Authorization: `bearer ${token}`,
62+
Accept: "application/vnd.github+json",
63+
"X-GitHub-Api-Version": "2022-11-28",
64+
};
65+
}
66+
67+
async function fetchPagesUrl(repoName: string, token: string): Promise<string | undefined> {
68+
try {
69+
const res = await fetch(`https://api.github.com/repos/${GITHUB_OWNER}/${repoName}/pages`, {
70+
headers: buildHeaders(token),
71+
});
72+
73+
if (res.status === 404) return undefined;
74+
if (!res.ok) {
75+
console.error(`[github] Pages API responded ${res.status} for ${repoName}`);
76+
return undefined;
77+
}
78+
79+
const pages = (await res.json()) as PagesResponse;
80+
return pages.html_url || undefined;
81+
} catch (err) {
82+
console.error(`[github] Pages lookup failed for ${repoName}:`, err);
83+
return undefined;
84+
}
85+
}
86+
87+
function normalizeRepo(n: RepoNode): GitHubRepo {
88+
const homepageUrl = n.homepageUrl?.trim() || undefined;
89+
90+
return {
91+
title: n.name,
92+
description: n.description || "",
93+
url: n.url,
94+
lang: n.primaryLanguage?.name || "",
95+
langColor: n.primaryLanguage?.color || "#888",
96+
stars: n.stargazerCount,
97+
homepageUrl,
98+
pagesUrl: homepageUrl?.startsWith(`https://${GITHUB_OWNER}.github.io/`) ? homepageUrl : undefined,
99+
updatedAt: n.updatedAt,
100+
};
101+
}
102+
46103
export async function fetchRepos(): Promise<GitHubRepo[]> {
47104
const token = import.meta.env.GITHUB_TOKEN;
48105
if (!token) {
@@ -54,7 +111,7 @@ export async function fetchRepos(): Promise<GitHubRepo[]> {
54111
const res = await fetch("https://api.github.com/graphql", {
55112
method: "POST",
56113
headers: {
57-
Authorization: `bearer ${token}`,
114+
...buildHeaders(token),
58115
"Content-Type": "application/json",
59116
},
60117
body: JSON.stringify({ query: QUERY }),
@@ -73,17 +130,16 @@ export async function fetchRepos(): Promise<GitHubRepo[]> {
73130
return FALLBACK;
74131
}
75132

76-
return nodes
77-
.filter((n: RepoNode) => !EXCLUDE_REPOS.includes(n.name))
78-
.map((n: RepoNode) => ({
79-
title: n.name,
80-
description: n.description || "",
81-
url: n.url,
82-
lang: n.primaryLanguage?.name || "",
83-
langColor: n.primaryLanguage?.color || "#888",
84-
stars: n.stargazerCount,
85-
homepageUrl: n.homepageUrl || undefined,
86-
}));
133+
const repos = nodes
134+
.filter((n: RepoNode) => n.owner.login === GITHUB_OWNER && !EXCLUDE_REPOS.has(n.name))
135+
.map(normalizeRepo);
136+
137+
const pagesUrls = await Promise.all(repos.map((repo) => fetchPagesUrl(repo.title, token)));
138+
139+
return repos.map((repo, index) => ({
140+
...repo,
141+
pagesUrl: pagesUrls[index] || repo.pagesUrl,
142+
}));
87143
} catch (err) {
88144
console.error("[github] Fetch failed:", err);
89145
return FALLBACK;

src/styles/globals.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,22 @@ body::after {
739739
color: var(--color-green);
740740
}
741741

742+
.repo-ledger-heading {
743+
max-width: 760px;
744+
margin: 2.5rem 0 1rem;
745+
}
746+
.repo-ledger-heading h3 {
747+
margin-top: 0.35rem;
748+
color: var(--color-text);
749+
font-size: clamp(1.35rem, 3vw, 2rem);
750+
font-weight: 850;
751+
}
752+
.repo-ledger-heading > p:last-child {
753+
margin-top: 0.55rem;
754+
color: var(--color-muted);
755+
line-height: 1.65;
756+
}
757+
742758
.repo-table {
743759
display: grid;
744760
border-top: 1px solid var(--color-border);

wiki/deployment.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ The site deploys automatically via `.github/workflows/deploy.yml` on every push
1111
3. **Setup Pages** — Configure GitHub Pages
1212
4. **Install**`npm ci`
1313
5. **Build**`npm run build` with environment variables:
14-
- `GITHUB_TOKEN` — Fetches pinned repos (auto-provided by GitHub Actions)
14+
- `GITHUB_TOKEN` — Fetches public repositories and Pages URLs (auto-provided by GitHub Actions)
1515
- `NEXT_PUBLIC_GA_ID` — Google Analytics measurement ID
1616
6. **Upload** — Uploads `out/` directory as Pages artifact
1717
7. **Deploy** — Deploys to GitHub Pages
@@ -20,7 +20,7 @@ The site deploys automatically via `.github/workflows/deploy.yml` on every push
2020

2121
| Variable | Source | Purpose |
2222
|---|---|---|
23-
| `GITHUB_TOKEN` | `secrets.GITHUB_TOKEN` (auto) | GitHub GraphQL API for pinned repos |
23+
| `GITHUB_TOKEN` | `secrets.GITHUB_TOKEN` (auto) | GitHub GraphQL and REST APIs for public repo + Pages data |
2424
| `NEXT_PUBLIC_GA_ID` | Hardcoded in workflow | GA4 measurement ID |
2525

2626
## Static Export

wiki/dynamic_projects.md

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,56 +2,63 @@
22

33
## How It Works
44

5-
The projects section is **not hardcoded** — it fetches your **pinned repositories** from GitHub at build time using the GraphQL API.
5+
The Workbench repository ledger is dynamic. During the Astro build, `src/lib/github.ts` fetches Jonathan's owned public, non-fork repositories from GitHub, excludes profile/portfolio metadata repos, and enriches each row with its live GitHub Pages URL when Pages is enabled.
6+
7+
Featured project cards remain curated in `src/lib/data.ts` so the top of the Workbench can emphasize the strongest portfolio examples. The dynamic ledger lists the remaining repositories below those cards.
68

79
## Data Flow
810

9-
1. `page.tsx` (Server Component) calls `fetchPinnedRepos()` from `lib/github.ts`
10-
2. `github.ts` sends a GraphQL query to the GitHub API:
11+
1. `src/pages/index.astro` calls `fetchRepos()` from `src/lib/github.ts`.
12+
2. `github.ts` sends a GraphQL query to GitHub for public repositories ordered by recent update:
1113

1214
```graphql
1315
{
1416
user(login: "jonathanperis") {
15-
pinnedItems(first: 6, types: REPOSITORY) {
17+
repositories(first: 100, privacy: PUBLIC, orderBy: { field: UPDATED_AT, direction: DESC }, isFork: false) {
1618
nodes {
17-
... on Repository {
18-
name
19-
description
20-
url
21-
stargazerCount
22-
primaryLanguage { name color }
23-
}
19+
name
20+
description
21+
url
22+
homepageUrl
23+
stargazerCount
24+
updatedAt
25+
primaryLanguage { name color }
2426
}
2527
}
2628
}
2729
}
2830
```
2931

30-
3. The response is mapped to `PinnedRepo[]` and passed to `portfolio.tsx` as a prop
31-
4. At build time, this data is baked into the static HTML
32+
3. For each included repository, `github.ts` also checks the REST Pages endpoint: `GET /repos/jonathanperis/{repo}/pages`.
33+
4. The response is mapped to `GitHubRepo[]` and passed to the React `Portfolio` component.
34+
5. At build time, this data is baked into the static HTML.
3235

3336
## Filtering
3437

35-
- `jonathanperis.github.io` (this repo) is automatically excluded from the list
36-
- Only pinned repos are shown — pin/unpin repos on GitHub to control what appears
38+
The GitHub query excludes forks via `isFork: false`; the mapper also requires `owner.login` to match `jonathanperis` so collaborator repositories do not appear.
3739

38-
## Play URLs
40+
The code also excludes repositories that should not appear in the public Workbench ledger:
3941

40-
Some projects have a "Play in browser" button. This is configured in `github.ts`:
42+
- `jonathanperis.github.io` — this portfolio repo
43+
- `.github` — organization/profile metadata
44+
- `jonathanperis` — profile/readme metadata
4145

42-
```typescript
43-
const PLAY_URLS: Record<string, string> = {
44-
"super-mango-game": "https://jonathanperis.github.io/super-mango-game/",
45-
};
46-
```
46+
Featured project slugs from `FEATURED_PROJECTS` are removed from the ledger so they are not duplicated below the curated cards.
47+
48+
## GitHub Pages Links
49+
50+
`pagesUrl` is the preferred live link and comes from the GitHub Pages REST API. If a repo uses a standard `https://jonathanperis.github.io/<repo>/` homepage value, that is used as a fallback Pages URL.
51+
52+
If a repository has a non-Pages homepage, the UI can show it separately as `homepage`.
4753

4854
## Fallback
4955

50-
If `GITHUB_TOKEN` is not available (e.g., local dev without it), hardcoded fallback data is used so the site always builds.
56+
If `GITHUB_TOKEN` is not available, hardcoded fallback data is used so the site always builds locally and in constrained environments.
5157

5258
## Updating Projects
5359

54-
To update the projects on the live site:
55-
1. Pin/unpin repos on your GitHub profile
56-
2. Push any change to trigger a deploy
57-
3. The build fetches fresh data from GitHub
60+
To update the live repository ledger:
61+
62+
1. Push or update the target GitHub repository.
63+
2. Enable GitHub Pages on that repo if it should expose a live Pages link.
64+
3. Push any change to this portfolio, or manually run the Pages workflow, so the build fetches fresh GitHub data.

0 commit comments

Comments
 (0)