Yet another version of my portfolio https://tomplanche.com
  • TypeScript 42.4%
  • Svelte 32.3%
  • Rust 17.9%
  • Shell 3.1%
  • SCSS 2%
  • Other 2.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-28 01:30:40 +01:00
.vscode (chore on blog/AT-integration) Prettier config + lint:fix 2026-09-03 14:13:10 +02:00
md-to-blog-post (chore on blog/AT-integration) Prettier config + lint:fix 2026-09-03 14:13:10 +02:00
scripts (feat on planes) Better scripts for main and next. websites 2026-10-27 02:43:26 +01:00
src (feat on planes) Cleaner self-host info + cleaner projects list 2026-10-28 01:30:40 +01:00
static (feat on planes) Add apache iggy project 2026-10-27 00:30:36 +01:00
.env.example (feat on main) Add build caddy file 2026-09-08 18:49:30 +02:00
.gitignore (feat on planes) Better scripts for main and next. websites 2026-10-27 02:43:26 +01:00
.npmrc (chore on blog) Update deps, update rona medium, clean Footer component 2026-06-29 06:14:19 +02:00
.prettierignore (chore on blog/AT-integration) Prettier config + lint:fix 2026-09-03 14:13:10 +02:00
.prettierrc (chore on blog/AT-integration) Prettier config + lint:fix 2026-09-03 14:13:10 +02:00
ecosystem.config.cjs (feat on planes) Better scripts for main and next. websites 2026-10-27 02:43:26 +01:00
eslint.config.js (chore on blog/AT-integration) Prettier config + lint:fix 2026-09-03 14:13:10 +02:00
package.json (feat on planes) Better scripts for main and next. websites 2026-10-27 02:43:26 +01:00
pnpm-lock.yaml (chore on blog/AT-integration) Prettier config + lint:fix 2026-09-03 14:13:10 +02:00
pnpm-workspace.yaml (fix on main) Fix pnpm-workspace 2026-09-01 12:59:35 +02:00
README.md (feat on main) Accepts text/markdown header and returns markdown ! 2026-09-15 18:34:01 +02:00
svelte.config.js (chore on blog/AT-integration) Prettier config + lint:fix 2026-09-03 14:13:10 +02:00
tsconfig.json (chore on blog/AT-integration) Prettier config + lint:fix 2026-09-03 14:13:10 +02:00
vite.config.ts (feat on main) Add build caddy file 2026-09-08 18:49:30 +02:00

tomplanche.com

Personal portfolio site. Single-page, server-side rendered, built with a focus on small details over heavy dependencies.

Stack

  • SvelteKit with Svelte 5 (runes-based reactivity: $state, $props, $effect)
  • TypeScript throughout
  • SCSS for styles
  • Vite as the build tool
  • pnpm as the package manager, Node as the runtime
  • Node adapter (@sveltejs/adapter-node) for self-hosted deployment
  • Rust for md-to-blog-post, the converter that turns a Markdown note into a blog post

Notable pieces

Pixel reveal transitions

Two canvas-based pixel dissolve animations drive the visual transitions:

  • PixelReveal runs as a full-screen overlay on page load. A grid of rectangles sweeps top-to-bottom, each cell briefly flashing blue before clearing to expose the page.
  • ImageReveal wraps individual project cards. The same dissolve mechanic is scoped to the element's bounding box and fires once the element enters the viewport via IntersectionObserver. It waits for the page-enter animation to complete first.

Both effects draw directly to a <canvas> element each frame using requestAnimationFrame, with per-cell timing noise added around a linear sweep to avoid a mechanical look. The approach is inspired by the scroll-revealed WebGL gallery technique described at tympanus.net/codrops/2026/02/02/building-a-scroll-revealed-webgl-gallery-with-gsap-three-js-astro-and-barba-js.

Interactive globe

The projects section includes a draggable 3D globe built with a fork of Cobe, pulled straight from its Git branch in package.json while pending PRs are merged upstream. A Git checkout ships no build output, so pnpm has to run the package's own build step; that is what the allowBuilds entry for cobe in pnpm-workspace.yaml permits. A set of satellite markers is distributed across the surface with arcs connecting them. If the visitor grants geolocation permission, their coordinates are plotted as an additional marker and arcs are drawn from the four nearest satellites to their location.

Geolocation state

A small Svelte 5 reactive store (geolocation.svelte.ts) wraps navigator.geolocation.getCurrentPosition. It exposes coords, error, and loading as reactive getters, making it easy to wire into any component without a framework or library.

Noise overlay

A fixed, full-viewport <div> tiled with a looping noise GIF sits above everything at a low opacity. It adds a subtle film grain texture without any JS.

The footer pulls the latest Git commit SHA and URL from the GitHub API at request time (via the SvelteKit layout server load). It links directly to the commit on GitHub.

Blog posts

A post is a plain Svelte component in src/lib/posts/, but it is not written by hand: it is generated from a Markdown note by md-to-blog-post, a small Rust CLI kept in this repository.

The note is a selfnotes note, read the way selfnotes reads it. Its +++ TOML frontmatter and its inline #tags prefill the post's metadata (title, date, description, tags, draft), each field falling back to the body, the file path, then the file name when the frontmatter says nothing. The body becomes the semantic HTML the .prose wrapper styles: fenced code blocks are lifted into CodeBlock components with their language, file name and highlight options taken from the info string, inline code becomes <Code>, [[wikilinks]] become /blog/<slug> links, and braces and inline HTML are escaped so Svelte does not read them as markup or as an expression.

The notes live next to the posts, in src/lib/posts/sources/. scripts/transcribe-posts.sh builds the converter and re-transcribes every note, or just the ones named on the command line:

scripts/transcribe-posts.sh                 # every note in sources/
scripts/transcribe-posts.sh monclub-bot     # just that one, by slug

A generated post is always overwritten rather than merged, so the note is the thing to edit. The converter's own README documents every flag and every code-block option.

Markdown for agents

Every page answers Accept: text/markdown with a Markdown version of itself, so an agent reads the content instead of scraping the layout. HTML stays the default: a browser, and a bare curl sending the catch-all type, see no change.

curl https://tomplanche.com/blog/monclub-bot -H 'Accept: text/markdown'

The response is text/markdown; charset=utf-8, carries Vary: Accept so a cache keeps the two versions apart, and reports its own size in x-markdown-tokens. The document opens with a YAML frontmatter block holding the page's title, description, image and canonical URL, which is the shape Cloudflare's Markdown for Agents returns, so a client written against a Cloudflare zone reads this site unchanged.

A blog post is served from the note it was transcribed from, which is already Markdown. Every other page is converted from the HTML it just rendered, by src/lib/server/markdown/, so no page has to be written twice and none can drift from its Markdown. src/hooks.server.ts is what picks between the two representations; the routes themselves know nothing about it.

Deployment

The site is served by the adapter-node build behind Caddy. The reverse-proxy site block is not committed: pnpm build writes it to deploy/portfolio.caddy from DOMAIN (and PORT, default 3000) in .env, so a fork or a staging host only changes those variables. When DOMAIN is unset the build says so and writes nothing.

tomplanche.com {
	reverse_proxy localhost:3000
}

Point the server-wide Caddyfile at it with import /path/to/portfolio-v222/deploy/*.caddy.

The server itself runs under pm2, configured by ecosystem.config.cjs:

pm2 start ecosystem.config.cjs   # first run
pm2 restart portfolio            # after a rebuild

It starts build/index.js with --env-file-if-exists=.env, so .env is the only place PORT is set: a variable already in pm2's environment would win over the file, and the build reads that same value when it writes the Caddy block. Without pm2, scripts/serve.sh does the equivalent from a shell.