From de7e70aadf94365d4ccd7a9a5249dcccf981aa59 Mon Sep 17 00:00:00 2001 From: Jonathan Peris Date: Sun, 17 May 2026 00:29:04 +0000 Subject: [PATCH] feat: dynamically list workbench repositories --- README.md | 21 ++++---- src/components/Portfolio.tsx | 18 ++++--- src/lib/github.ts | 100 +++++++++++++++++++++++++++-------- src/styles/globals.css | 16 ++++++ wiki/deployment.md | 4 +- wiki/dynamic_projects.md | 61 +++++++++++---------- wiki/index.md | 2 +- 7 files changed, 153 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index cbfcd32..13055ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # jonathanperis.github.io -> Personal developer portfolio built with Next.js — dynamically fetches GitHub projects, dark terminal aesthetic, print-optimized resume +> Personal developer portfolio built with Astro — dynamically fetches GitHub projects, dark terminal aesthetic, print-optimized resume [![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) @@ -10,7 +10,7 @@ ## About -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. +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. 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. @@ -20,16 +20,17 @@ It is built to stay simple to deploy: build locally, export statically, and publ | Technology | Version | Purpose | |-----------|---------|---------| -| Next.js | 16 | App Router site with static export | -| React | 19 | UI rendering | +| Astro | 6 | Static site build and GitHub Pages export | +| React | 19 | Interactive portfolio UI | | TypeScript | Latest | Type safety | | Tailwind CSS | v4 | Styling system | -| GitHub GraphQL API | v4 | Fetches pinned repos at build time | +| GitHub GraphQL + REST APIs | v4 / REST | Fetches repositories and live Pages URLs at build time | | Google Analytics 4 | GA4 | Traffic and engagement analytics | ## Features -- Dynamic projects from GitHub GraphQL API (pinned repos) +- Dynamic Workbench repository ledger from GitHub GraphQL API (public, non-fork repos) +- Live GitHub Pages links resolved at build time via GitHub REST API - Terminal-themed dark UI with typing animations and scroll effects - Print-optimized resume page with download support - PWA manifest and SEO optimizations @@ -40,18 +41,18 @@ It is built to stay simple to deploy: build locally, export statically, and publ ### Prerequisites -- Node.js 18+, npm +- Node.js 22+, Bun ### Quick Start ```bash git clone https://github.com/jonathanperis/jonathanperis.github.io.git cd jonathanperis.github.io -npm install -npm run dev +bun install +bun run dev ``` -Open http://localhost:3000 +Open http://localhost:4321 ## CI/CD diff --git a/src/components/Portfolio.tsx b/src/components/Portfolio.tsx index 9e8c0fa..ea0c45b 100644 --- a/src/components/Portfolio.tsx +++ b/src/components/Portfolio.tsx @@ -162,6 +162,7 @@ function projectLane(project: (typeof FEATURED_PROJECTS)[number]) { export default function Portfolio({ projects }: { projects: GitHubRepo[] }) { const scrollProgress = useScrollProgress(); const featuredSlugs = useMemo(() => new Set(FEATURED_PROJECTS.map((fp) => fp.slug)), []); + const workbenchRepos = useMemo(() => projects.filter((project) => !featuredSlugs.has(project.title)), [featuredSlugs, projects]); const [termOpen, setTermOpen] = useState(false); const [termInput, setTermInput] = useState(""); @@ -395,14 +396,16 @@ export default function Portfolio({ projects }: { projects: GitHubRepo[] }) { ))} - -
- - Repository tail + +
+

public repository ledger

+

All non-fork GitHub work

+

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.

+
- {projects.filter((p) => !featuredSlugs.has(p.title) && p.title !== ".github").slice(0, 8).map((project, index) => ( + {workbenchRepos.map((project, index) => (
{project.title} @@ -410,13 +413,14 @@ export default function Portfolio({ projects }: { projects: GitHubRepo[] }) {
{project.stars > 0 && {project.stars} stars} {project.lang && {project.lang}} - {project.homepageUrl && site} + {project.pagesUrl && GitHub Pages} + {project.homepageUrl && project.homepageUrl !== project.pagesUrl && homepage}
))}
- + View all repositories on GitHub
diff --git a/src/lib/github.ts b/src/lib/github.ts index 1513927..8c9279c 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -6,21 +6,26 @@ export type GitHubRepo = { langColor: string; stars: number; homepageUrl?: string; + pagesUrl?: string; + updatedAt?: string; }; -const EXCLUDE_REPOS = ["jonathanperis.github.io", "jonathanperis"]; +const GITHUB_OWNER = "jonathanperis"; +const EXCLUDE_REPOS = new Set(["jonathanperis.github.io", ".github", "jonathanperis"]); const FALLBACK: GitHubRepo[] = [ - { 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/" }, - { 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/" }, - { 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/" }, - { 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/" }, - { 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/" }, - { 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 }, + { 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/" }, + { 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/" }, + { 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/" }, + { 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/" }, + { 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/" }, + { 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/" }, + { 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/" }, + { 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/" }, ]; const QUERY = `{ - user(login: "jonathanperis") { + user(login: "${GITHUB_OWNER}") { repositories(first: 100, privacy: PUBLIC, orderBy: { field: UPDATED_AT, direction: DESC }, isFork: false) { nodes { name @@ -28,6 +33,8 @@ const QUERY = `{ url homepageUrl stargazerCount + updatedAt + owner { login } primaryLanguage { name color } } } @@ -36,13 +43,63 @@ const QUERY = `{ type RepoNode = { name: string; - description: string; + description: string | null; url: string; - homepageUrl?: string; + homepageUrl?: string | null; stargazerCount: number; + updatedAt?: string; + owner: { login: string }; primaryLanguage: { name: string; color: string } | null; }; +type PagesResponse = { + html_url?: string; +}; + +function buildHeaders(token: string) { + return { + Authorization: `bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; +} + +async function fetchPagesUrl(repoName: string, token: string): Promise { + try { + const res = await fetch(`https://api.github.com/repos/${GITHUB_OWNER}/${repoName}/pages`, { + headers: buildHeaders(token), + }); + + if (res.status === 404) return undefined; + if (!res.ok) { + console.error(`[github] Pages API responded ${res.status} for ${repoName}`); + return undefined; + } + + const pages = (await res.json()) as PagesResponse; + return pages.html_url || undefined; + } catch (err) { + console.error(`[github] Pages lookup failed for ${repoName}:`, err); + return undefined; + } +} + +function normalizeRepo(n: RepoNode): GitHubRepo { + const homepageUrl = n.homepageUrl?.trim() || undefined; + + return { + title: n.name, + description: n.description || "", + url: n.url, + lang: n.primaryLanguage?.name || "", + langColor: n.primaryLanguage?.color || "#888", + stars: n.stargazerCount, + homepageUrl, + pagesUrl: homepageUrl?.startsWith(`https://${GITHUB_OWNER}.github.io/`) ? homepageUrl : undefined, + updatedAt: n.updatedAt, + }; +} + export async function fetchRepos(): Promise { const token = import.meta.env.GITHUB_TOKEN; if (!token) { @@ -54,7 +111,7 @@ export async function fetchRepos(): Promise { const res = await fetch("https://api.github.com/graphql", { method: "POST", headers: { - Authorization: `bearer ${token}`, + ...buildHeaders(token), "Content-Type": "application/json", }, body: JSON.stringify({ query: QUERY }), @@ -73,17 +130,16 @@ export async function fetchRepos(): Promise { return FALLBACK; } - return nodes - .filter((n: RepoNode) => !EXCLUDE_REPOS.includes(n.name)) - .map((n: RepoNode) => ({ - title: n.name, - description: n.description || "", - url: n.url, - lang: n.primaryLanguage?.name || "", - langColor: n.primaryLanguage?.color || "#888", - stars: n.stargazerCount, - homepageUrl: n.homepageUrl || undefined, - })); + const repos = nodes + .filter((n: RepoNode) => n.owner.login === GITHUB_OWNER && !EXCLUDE_REPOS.has(n.name)) + .map(normalizeRepo); + + const pagesUrls = await Promise.all(repos.map((repo) => fetchPagesUrl(repo.title, token))); + + return repos.map((repo, index) => ({ + ...repo, + pagesUrl: pagesUrls[index] || repo.pagesUrl, + })); } catch (err) { console.error("[github] Fetch failed:", err); return FALLBACK; diff --git a/src/styles/globals.css b/src/styles/globals.css index 74ccce3..95d83bf 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -739,6 +739,22 @@ body::after { color: var(--color-green); } +.repo-ledger-heading { + max-width: 760px; + margin: 2.5rem 0 1rem; +} +.repo-ledger-heading h3 { + margin-top: 0.35rem; + color: var(--color-text); + font-size: clamp(1.35rem, 3vw, 2rem); + font-weight: 850; +} +.repo-ledger-heading > p:last-child { + margin-top: 0.55rem; + color: var(--color-muted); + line-height: 1.65; +} + .repo-table { display: grid; border-top: 1px solid var(--color-border); diff --git a/wiki/deployment.md b/wiki/deployment.md index 6d3b56c..d5c1c59 100644 --- a/wiki/deployment.md +++ b/wiki/deployment.md @@ -11,7 +11,7 @@ The site deploys automatically via `.github/workflows/deploy.yml` on every push 3. **Setup Pages** — Configure GitHub Pages 4. **Install** — `npm ci` 5. **Build** — `npm run build` with environment variables: - - `GITHUB_TOKEN` — Fetches pinned repos (auto-provided by GitHub Actions) + - `GITHUB_TOKEN` — Fetches public repositories and Pages URLs (auto-provided by GitHub Actions) - `NEXT_PUBLIC_GA_ID` — Google Analytics measurement ID 6. **Upload** — Uploads `out/` directory as Pages artifact 7. **Deploy** — Deploys to GitHub Pages @@ -20,7 +20,7 @@ The site deploys automatically via `.github/workflows/deploy.yml` on every push | Variable | Source | Purpose | |---|---|---| -| `GITHUB_TOKEN` | `secrets.GITHUB_TOKEN` (auto) | GitHub GraphQL API for pinned repos | +| `GITHUB_TOKEN` | `secrets.GITHUB_TOKEN` (auto) | GitHub GraphQL and REST APIs for public repo + Pages data | | `NEXT_PUBLIC_GA_ID` | Hardcoded in workflow | GA4 measurement ID | ## Static Export diff --git a/wiki/dynamic_projects.md b/wiki/dynamic_projects.md index 933d6e9..aa8a52d 100644 --- a/wiki/dynamic_projects.md +++ b/wiki/dynamic_projects.md @@ -2,56 +2,63 @@ ## How It Works -The projects section is **not hardcoded** — it fetches your **pinned repositories** from GitHub at build time using the GraphQL API. +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. + +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. ## Data Flow -1. `page.tsx` (Server Component) calls `fetchPinnedRepos()` from `lib/github.ts` -2. `github.ts` sends a GraphQL query to the GitHub API: +1. `src/pages/index.astro` calls `fetchRepos()` from `src/lib/github.ts`. +2. `github.ts` sends a GraphQL query to GitHub for public repositories ordered by recent update: ```graphql { user(login: "jonathanperis") { - pinnedItems(first: 6, types: REPOSITORY) { + repositories(first: 100, privacy: PUBLIC, orderBy: { field: UPDATED_AT, direction: DESC }, isFork: false) { nodes { - ... on Repository { - name - description - url - stargazerCount - primaryLanguage { name color } - } + name + description + url + homepageUrl + stargazerCount + updatedAt + primaryLanguage { name color } } } } } ``` -3. The response is mapped to `PinnedRepo[]` and passed to `portfolio.tsx` as a prop -4. At build time, this data is baked into the static HTML +3. For each included repository, `github.ts` also checks the REST Pages endpoint: `GET /repos/jonathanperis/{repo}/pages`. +4. The response is mapped to `GitHubRepo[]` and passed to the React `Portfolio` component. +5. At build time, this data is baked into the static HTML. ## Filtering -- `jonathanperis.github.io` (this repo) is automatically excluded from the list -- Only pinned repos are shown — pin/unpin repos on GitHub to control what appears +The GitHub query excludes forks via `isFork: false`; the mapper also requires `owner.login` to match `jonathanperis` so collaborator repositories do not appear. -## Play URLs +The code also excludes repositories that should not appear in the public Workbench ledger: -Some projects have a "Play in browser" button. This is configured in `github.ts`: +- `jonathanperis.github.io` — this portfolio repo +- `.github` — organization/profile metadata +- `jonathanperis` — profile/readme metadata -```typescript -const PLAY_URLS: Record = { - "super-mango-game": "https://jonathanperis.github.io/super-mango-game/", -}; -``` +Featured project slugs from `FEATURED_PROJECTS` are removed from the ledger so they are not duplicated below the curated cards. + +## GitHub Pages Links + +`pagesUrl` is the preferred live link and comes from the GitHub Pages REST API. If a repo uses a standard `https://jonathanperis.github.io//` homepage value, that is used as a fallback Pages URL. + +If a repository has a non-Pages homepage, the UI can show it separately as `homepage`. ## Fallback -If `GITHUB_TOKEN` is not available (e.g., local dev without it), hardcoded fallback data is used so the site always builds. +If `GITHUB_TOKEN` is not available, hardcoded fallback data is used so the site always builds locally and in constrained environments. ## Updating Projects -To update the projects on the live site: -1. Pin/unpin repos on your GitHub profile -2. Push any change to trigger a deploy -3. The build fetches fresh data from GitHub +To update the live repository ledger: + +1. Push or update the target GitHub repository. +2. Enable GitHub Pages on that repo if it should expose a live Pages link. +3. Push any change to this portfolio, or manually run the Pages workflow, so the build fetches fresh GitHub data. diff --git a/wiki/index.md b/wiki/index.md index fb398dd..028c475 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -8,7 +8,7 @@ Personal developer portfolio for **Jonathan Peris** — Software Engineer with 1 - Developer-themed dark UI with terminal aesthetic - Typing role animation, scroll animations, progress bar -- Dynamic GitHub pinned repos fetched at build time via GraphQL API +- Dynamic Workbench repository ledger fetched at build time via GitHub GraphQL + Pages REST APIs - Print-optimized resume page generated from shared data (`/resume`) - Interactive terminal easter egg (Konami code) - SEO optimized: JSON-LD, sitemap, robots.txt, Open Graph, Twitter cards