← Projects · 2026-04-06 · active

goonk.se

Personal site reboot: simple, fast, and self-hosted.

  • Migrated 100+ WordPress posts to Markdown via a custom XML → Turndown conversion pipeline
  • Astro static site with Markdown-driven project pages and content collections
  • Self-hostable via Docker + nginx (plus optional Immich photo proxy)
  • Hidden easter eggs and achievement system for curious visitors

A complete reboot of my personal site after years of WordPress — including a full migration of 100+ blog posts from the WordPress XML export to Astro Markdown.

What it is

  • A small collection of pages: CV, highlights, projects, blog, photos, homelab
  • 100+ blog posts migrated from WordPress, going back to ~2010
  • Markdown-driven project write-ups in src/content/projects/
  • Static site generation with Astro
  • Dockerized deployment: built on my own CI, served from my own VPS
  • Hidden easter eggs and achievements for curious visitors

Why I rebuilt it

Years of WordPress felt heavy. I wanted something where adding a project or a blog post was just dropping a Markdown file in a folder. No database, no plugin updates, no security patches at 2am.

Also wanted full control over hosting and styling without fighting themes.

The migration

The old blog ran on WordPress with around 100 posts from 2010 onward. WordPress’s export format is a standard RSS XML file with all post content stored as HTML, so migration meant:

  1. Parsing the XML export with xml2js
  2. Converting post HTML to Markdown with turndown, with custom rules for WordPress-specific markup (caption shortcodes, figure elements, embedded media)
  3. Rewriting internal links from /year/month/slug to the flat /blog/slug structure
  4. Downloading all media from the WordPress media library and rewriting image paths
  5. Mapping WordPress post metadata (title, date, categories, tags, author) to Astro frontmatter

Each post landed as a .md file in src/content/blog/. The content collection schema validates every post at build time — if a frontmatter field is missing or malformed, the build fails loudly.

Tech stack

  • Astro: Static site generator, fast builds, no client-side JS bloat
  • TypeScript: Type safety for content collections and frontmatter
  • Docker + nginx: Self-hosted deployment with reverse proxy
  • Gitea CI/CD: Automated builds on push to main branch
  • API sidecar: Small Node/Express service for the live bits: Immich photos, Last.fm, Spotify, Gitea activity, admin

Features

  • Content collections: Type-safe markdown with Zod schemas
  • Project pages: Automatic routing from src/content/projects/*.md
  • Debug mode: Hidden developer comments throughout (click the “Updated” date)
  • Easter eggs: 25 hidden achievements for explorers (Konami code, typed commands, URL params, secret pages)
  • Achievement system: LocalStorage-persisted tracking with milestone rewards
  • Photo integration: Pull images from self-hosted Immich instance
  • Responsive design: Works on mobile, doesn’t look like 1998 (unless you trigger ?1998=true)

Fun stuff

The site has a complete achievement system with easter eggs scattered throughout:

  • Hidden pages like /secret
  • Konami code (↑↑↓↓←→←→BA)
  • Typed commands (try typing “gravity”)
  • URL parameters (?matrix=true, ?coffee=true, ?dark=true)
  • Time-based surprises (visit between midnight and 4am)
  • Interactive elements (click the profile photo 10 times)
  • Paint mode, vim mode, emacs mode
  • And more…

Check out the /achievements page to see what you’ve unlocked.

Deployment

Gitea CI builds and pushes the images on hardware in my homelab whenever I push to main; the site itself runs in Docker on a Hetzner VPS:

docker compose up -d

Two containers: nginx serves the static build and proxies /api/ to the Node sidecar, which handles everything that has to be fetched live. Under the hood has the full path from commit to running container.

Live at goonk.se, which you’re on.