This site — a headless Vue 3 SPA consuming a Slim 4 PHP API with flat-file YAML and Markdown content.
View on GitHubThis site is a rebuild of my previous Drupal-based portfolio. Drupal made content updates easy, but changing layout or visual design meant working through modules and a theming layer — more overhead than I wanted for a site only I maintain. The goal was to shed that CMS overhead while keeping content editable without touching code — a headless setup with a flat-file backend turned out to be the right fit. I go into more detail on why here.
The frontend is a Vue 3 SPA; the backend is a Slim 4 PHP API serving YAML and Markdown content files. There is no database.
The frontend is built with Vue 3 using the Composition API throughout — no Options API. TypeScript is used across all components and composables.
Routing is handled by Vue Router with a named-outlet pattern for modals: detail routes render the list view as the default outlet and the item view in a modal outlet simultaneously, so the list stays mounted behind the overlay. Keeping the list alive in the background required a frozenComponent pattern to stop the modal-outlet component from re-mounting when navigating between items.
Tailwind CSS 4 handles layout and spacing. Decorative styles — pseudo-elements, clip-paths, polygon chevrons, animated glitch effects — live in scoped component styles rather than utilities.
The API is built on Slim 4 with PHP-DI for dependency injection. It exposes two endpoints:
GET /api/page/{page} — returns a fully resolved page layout, expanding manifests and content items into a single responseGET /api/content/{type}/{slug} — returns a single content item by type and slugContent is stored as YAML files with optional Markdown siblings for long-form body fields. The content directory lives outside version control and is synced separately.
Page layouts are YAML files checked into the repo under api/layouts/. They describe sections and reference manifests or content sources by name. The API resolves those references at request time — no build step.
The frontend is prerendered with vite-ssg. The build outputs flat HTML files (portfolio.html, not portfolio/index.html) and generates a sitemap via the ssgOptions.onFinished hook in vite.config.ts. Per-route <head> — title, description, canonical URL, OpenGraph, Twitter — comes from the useSeo composable built on @unhead/vue.
Body content hydrates client-side (fetched in onMounted), so the static HTML contains only the chrome and a loading placeholder — detail routes are intentionally client-rendered.
Build locally, rsync dist/ + content/ to the server. The server runs Nginx + PHP-FPM with no Node dependency. Content-only changes go live via rsync alone.
An optional Clippy-inspired assistant that appears on load and reacts to navigation. The backend has a ClippyService that calls the Anthropic API with a visitor-oriented persona prompt and caches the response pool in a flat JSON file with a TTL. The frontend fetches the pool once per session into sessionStorage, shuffles it, and de-duplicates across page visits. Clippy speaks, gestures, and shows scoped action buttons depending on which section of the site you're in.
The skills section renders as an interactive honeycomb grid. Each hexagon can be clicked to spin (desktop) or tapped (mobile), with touch-action and pointer-capture handling to avoid conflicts with page scroll. The hex radius is container-driven — computed from the wrapper width and clamped to a min/max — so the grid adapts from ~4 across on mobile to however many fit at larger widths.
3D card-flip effect on ContentCard components during page navigation. Modal entrances use fade + slide. Transitions are unlayered global CSS so they always win the cascade.
This was the project where I went deep on the Vue 3 Composition API. The composable pattern — extracting ref, onMounted, and fetch into reusable functions — maps cleanly to how I already think about separating data access from presentation.
TypeScript in Vue is rougher than TypeScript in a plain Node project. The interplay between defineProps, withDefaults, and template type inference has a learning curve, but it pays off once the IDE starts catching prop mismatches at edit time rather than runtime.
The SSG setup surfaced a recurring constraint: anything that touches window or document must be guarded behind onMounted or a dynamic import() — the prerender runs in Node and has neither.