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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

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

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

Expand Down
18 changes: 11 additions & 7 deletions src/components/Portfolio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down Expand Up @@ -395,28 +396,31 @@ export default function Portfolio({ projects }: { projects: GitHubRepo[] }) {
</Reveal>
))}
</div>
</section>

<section className="content-section" aria-labelledby="repos-heading">
<Reveal>
<SectionLabel id="repos-heading" number="05">Repository tail</SectionLabel>
<Reveal delay={320}>
<div className="repo-ledger-heading">
<p className="font-mono text-[10px] uppercase tracking-[0.28em] text-dim">public repository ledger</p>
<h3>All non-fork GitHub work</h3>
<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>
</div>
</Reveal>
<div className="repo-table" role="list">
{projects.filter((p) => !featuredSlugs.has(p.title) && p.title !== ".github").slice(0, 8).map((project, index) => (
{workbenchRepos.map((project, index) => (
<Reveal key={project.title} delay={index * 35}>
<article role="listitem" className="repo-row">
<a href={project.url} target="_blank" rel="noreferrer noopener">{project.title}</a>
<p>{project.description || "Repository note pending."}</p>
<div>
{project.stars > 0 && <span>{project.stars} stars</span>}
{project.lang && <span>{project.lang}</span>}
{project.homepageUrl && <a href={project.homepageUrl} target="_blank" rel="noreferrer noopener">site</a>}
{project.pagesUrl && <a href={project.pagesUrl} target="_blank" rel="noreferrer noopener">GitHub Pages</a>}
{project.homepageUrl && project.homepageUrl !== project.pagesUrl && <a href={project.homepageUrl} target="_blank" rel="noreferrer noopener">homepage</a>}
</div>
</article>
</Reveal>
))}
</div>
<Reveal delay={320}>
<Reveal delay={workbenchRepos.length * 35 + 120}>
<a href="https://github.com/jonathanperis" target="_blank" rel="noreferrer noopener" className="github-tail">View all repositories on GitHub</a>
</Reveal>
</section>
Expand Down
100 changes: 78 additions & 22 deletions src/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,35 @@ 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
description
url
homepageUrl
stargazerCount
updatedAt
owner { login }
primaryLanguage { name color }
}
}
Expand All @@ -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<string | undefined> {
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<GitHubRepo[]> {
const token = import.meta.env.GITHUB_TOKEN;
if (!token) {
Expand All @@ -54,7 +111,7 @@ export async function fetchRepos(): Promise<GitHubRepo[]> {
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 }),
Expand All @@ -73,17 +130,16 @@ export async function fetchRepos(): Promise<GitHubRepo[]> {
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;
Expand Down
16 changes: 16 additions & 0 deletions src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions wiki/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
61 changes: 34 additions & 27 deletions wiki/dynamic_projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"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/<repo>/` 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.
Loading