- TypeScript 87.4%
- JavaScript 4.5%
- CSS 4.3%
- Shell 2.4%
- Dockerfile 1.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
deploy / vercel (push) Successful in 3m22s
- hero: 'I built software for years. Now I take it apart.' replaces the break-things line - role is Application Security Engineer, AppSec Engineer in the header - about opens on the full-stack background instead of the 'I break things' framing - intro, about strip, keywords and CTA copy follow the same voice |
||
| .forgejo/workflows | ||
| app | ||
| components | ||
| content/cache | ||
| deploy | ||
| lib | ||
| public | ||
| scripts | ||
| .dockerignore | ||
| .gitignore | ||
| components.json | ||
| docker-compose.pi.yml | ||
| docker-compose.token.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| next.config.ts | ||
| package-lock.json | ||
| package.json | ||
| postcss.config.mjs | ||
| README.md | ||
| tsconfig.json | ||
| vercel.json | ||
portfolio
Personal site for John Paul Wile: security engineering, writing, and repositories. Next.js 15 (App Router) + Tailwind v4 + shadcn/ui + a hand-written WebGL grid. Static-first, no database, no auth.
What is on it
| Route | Content |
|---|---|
/ |
Hero with the interactive 3D silicon stack, selected repos, recent writing, about strip |
/work |
Every repository from the Forgejo API, with language + security filters |
/writing |
All Substack posts, grouped by year, mirrored from the RSS feed |
/about |
Bio, stack, experience, working principles |
/contact |
Working form (Resend) plus direct channels and a disclosure note |
/rss.xml, /sitemap.xml, /robots.txt |
Machine-readable surface |
Old URLs redirect: /newsletter -> /writing, /projects -> /work,
/tutorial -> /writing.
Data
scripts/sync-external.mjs runs on prebuild and pulls:
- repositories from the Forgejo API (
FORGEJO_URL, optionalFORGEJO_TOKEN) - posts from the Substack RSS feed
Both are written to content/cache/*.json, which is committed. Pages read only
the cache, so a dead API or a suspended account can never leave a page spinning
or fail a deploy - the last good payload ships. If a fetch fails and no cache
exists, the sync exits non-zero so the failure is loud once.
Refresh manually:
npm run sync
Environment
Copy .env.example to .env.local. Nothing here is client-side.
FORGEJO_URL- defaults tohttps://git.the424.comuntilgit.botsone.devexistsFORGEJO_TOKEN- optional; includes private repos, name-only, never linkedRESEND_API_KEY,CONTACT_FROM,CONTACT_TO- contact form delivery
Without RESEND_API_KEY the form returns 503 with a message pointing at the
direct email address instead of pretending to send.
Development
npm install
npm run dev # http://localhost:3000
npm run build # runs sync, then a static-first build
npm run lint
Hero background
components/grid-background.tsx is a hand-written WebGL fragment shader: an
infinite perspective floor grid that scrolls toward the viewer, with a light
that follows the pointer and tints nearby lines orange. No three.js, no geometry
- one fullscreen triangle.
It uses analytic pixel-footprint anti-aliasing so lines stay crisp at any depth,
picks its line colour from the --foreground token (so it works in both themes),
pauses when scrolled out of view or the tab is hidden, and draws a single static
frame under prefers-reduced-motion. Without WebGL it degrades to a CSS
gradient.
Deploy
Two targets, one build.
Vercel. Vercel only connects the Git providers it supports - GitHub, GitLab, Bitbucket. It cannot pull from Forgejo, so pick one of three routes:
-
CLI deploy, no Git link. Works today:
npx vercel login # once npx vercel link # once, writes .vercel/project.json npx vercel deploy --prod # uploads the source, Vercel builds itSimple, but no per-branch previews, and nothing deploys until you run it.
-
Forgejo Actions + CLI. Copy
deploy/vercel-deploy.ymlto.forgejo/workflows/deploy.ymlonce an Actions runner is registered, addVERCEL_TOKEN,VERCEL_ORG_IDandVERCEL_PROJECT_IDas repo secrets, and every push to main deploys. Forgejo stays the only source of truth. -
GitLab mirror for native Git integration (preview deploys, dashboard rollbacks). Create a push mirror in the Forgejo repo settings and connect the GitLab repo to Vercel. GitHub is out while that account is suspended.
vercel.json pins the framework and build command. output: "standalone" is
switched off automatically when VERCEL is set, so the same commit builds for
both targets.
The Actions runner on the Pi (forgejo-runner, label ubuntu-latest, container
code.forgejo.org/forgejo/runner:12) is registered to this repo. It needs
container.options: "--add-host=host.docker.internal:host-gateway" in its
config, otherwise job containers cannot resolve the instance for checkout.
Raspberry Pi (self-hosted). Docker + Caddy in front of the standalone server; roughly 250 MB of RAM at rest.
# on the Pi
sudo git clone https://git.the424.com/bots/portfolio.git /opt/portfolio
cd /opt/portfolio
sudo cp deploy/portfolio.env.example .env.selfhost # then edit
sudo docker compose build app # slow on a Pi: 10-20 min
sudo docker compose up -d
curl -s localhost/api/health
sudo bash deploy/install.sh does the same thing end to end and installs Docker
first if it is missing. It is idempotent, so re-run it to update.
Details:
- On a box that already terminates TLS (nginx-proxy-manager on the Pi), use the
override:
docker compose -f docker-compose.yml -f docker-compose.pi.yml up -dpublishes the app on :8080 and leaves :80/:443 alone. Point an NPM proxy host at it and NPM issues the certificate. - Caddy gets automatic HTTPS as soon as
SITE_ADDRESS=portfolio.example.comis set; without it Caddy serves plain HTTP on :80, which is what you want behind Cloudflare Tunnel or on a LAN. - Private repos at build time: put a token in
deploy/forgejo_tokenand build withdocker compose -f docker-compose.yml -f docker-compose.token.yml build. The token is mounted as a BuildKit secret, so it never lands in an image layer. - No port forwarding?
docker compose --profile tunnel up -dwithCLOUDFLARE_TUNNEL_TOKENin the environment runs cloudflared instead. - Nightly rebuild from main:
17 4 * * * /opt/portfolio/deploy/refresh.sh >> /var/log/portfolio-refresh.log 2>&1 - Not using Docker?
deploy/portfolio.serviceruns the standalone server under systemd: build withnpm run build, copy.next/standalone,.next/staticandpublicto/opt/portfolio, install the unit,systemctl enable --now portfolio.
NEXT_PUBLIC_SITE_URL decides which origin ends up in the canonical tags,
sitemap and RSS, so the Pi can serve a different hostname than the primary.