Personal blog of Bart Verkoeijen. VitePress v1 with a custom theme, Tailwind CSS v4, deployed to Cloudflare Workers with static assets.
Two licenses, deliberately:
- Code — MIT. Copyright (c) 2021-2026 Bart Verkoeijen.
- Content — CC BY-NC-SA 4.0. The writing and Bart's own images.
Third-party images (post covers licensed from Unsplash and elsewhere) are not covered by the
content license. Each is credited on its post through the coverSource frontmatter field and
listed in LICENSE-CONTENT. Clear those separately if you reuse anything.
Requires fnm (or any Node version manager honoring .nvmrc) and
pnpm 11, pinned by the packageManager field so Corepack, CI and Cloudflare all
use the same version. npm and yarn are blocked by a preinstall guard — the lockfile is pnpm's
and mixing package managers breaks it. .npmrc sets engine-strict=true, so the engines field is
enforced rather than advisory and an older Node fails the install instead of the build.
fnm use # Node 24, from .nvmrc
pnpm install
pnpm dev # http://localhost:5173| Command | What it does |
|---|---|
pnpm dev |
Dev server. Drafts are visible here and nowhere else. |
pnpm build |
Static build into dist/. Fails on malformed post frontmatter. |
pnpm preview |
Serve the built output locally. |
pnpm test |
Vitest: the Worker redirect table and frontmatter validation. |
pnpm typecheck |
vue-tsc --noEmit. |
pnpm format |
Prettier, including Tailwind class sorting. |
pnpm new-post "Title" |
Scaffold a post with complete frontmatter. |
pnpm deploy:worker |
Build and wrangler deploy. Normally unnecessary — see below. |
The deploy script is not called deploy: pnpm deploy is a reserved pnpm built-in (workspace
deploy) and would shadow the script rather than run it.
pnpm new-post "Why I rewrote my blog"That creates src/posts/<year>/<slug>.md, served at /<year>/<slug>.
---
title: Hello World, again! # required
date: 2021-07-03 # required, YYYY-MM-DD
description: One or two sentences. # required — meta, social cards, RSS
tags: [personal] # optional
cover: /covers/hello-world-again.webp # optional, must be a local file
coverAlt: What is actually in the image # required whenever cover is set
coverSource: https://unsplash.com/... # optional, renders the credit
updated: 2026-03-12 # optional, shown as "Updated …"
draft: true # optional, hides it from builds
---Validation is strict and throws: a missing description, a cover without coverAlt, or a remote
image URL fails the build rather than shipping a broken card or an inaccessible image. Covers
live in src/public/covers/ as WebP, 1600px wide.
The homepage card excerpt is the post's first paragraph. Drop a <!-- more --> marker in the body
to extend it.
.vitepress/
config.ts VitePress config: rewrites, sitemap, head, hooks
site.ts Single source of truth for site identity and links
head.ts Canonical, Open Graph, Twitter and JSON-LD tags
rss.ts RSS 2.0 feed, written in buildEnd
posts.data.ts Build-time content loader (validates, sorts, excerpts)
lib/ Post types, validation, date and slug helpers
theme/ The theme: layouts, components, Tailwind tokens, prose CSS
src/
index.md Homepage — intro header plus the 10 newest posts
about.md About
archive.md Every post, grouped by year
tags/ Tag index plus a generated page per tag
posts/<year>/ The posts themselves
public/ Covers, avatar, icons, robots.txt
worker/index.ts Redirects, then hands off to static assets
tests/ VitestA few decisions worth knowing before you change something:
- Post URLs are
/<year>/<slug>, not/posts/<year>/<slug>. Arewritesfunction inconfig.tsstrips theposts/prefix so the URLs the Eleventy site published keep working. This meanspageData.relativePathis already rewritten inside transform hooks — usefilePath(viaisPostPage()) to detect posts, notrelativePath. markdown.headersis enabled. The default theme builds its outline client-side; a custom theme needs headers inpageDatafor the table of contents to exist at all.- Dynamic route
paths()run before the config resolves, socreateContentLoaderis unavailable there.src/tags/[tag].paths.tsreads frontmatter off disk vialib/read-posts.ts. - No search. With one post it would be theater. When the archive justifies it, Pagefind is the intended path: a build-time index and a drop-in UI, about twenty lines plus a postbuild step.
- No analytics code. Cloudflare Web Analytics is enabled from the dashboard, so there is no beacon script, no cookie and nothing to consent to.
- The 404 page is built from
src/not-found.md. VitePress treats404.htmlas the SPA fallback and emits it with an empty body, since it cannot know the requested path at build time. Cloudflare serves that file directly for every unmatched URL, so anything not executing JavaScript would get a blank page.buildEndrenames the renderednot-found.htmlover it.
Pushes to main are built and deployed automatically by Cloudflare Workers Builds. GitHub
Actions runs the checks but does not deploy, so there are no Cloudflare credentials in GitHub.
These steps are done once, in the Cloudflare dashboard — they are not in this repo.
-
Create the Worker and connect the repo. Workers & Pages → Create → Workers → Connect to Git →
bgever/blog. Build settings:Setting Value Build command pnpm buildDeploy command pnpm exec wrangler deployRoot directory /Node version 24(or addNODE_VERSION=24as a build variable)Production branch mainWorkers Builds picks the package manager from the lockfile, so
pnpm-lock.yamlgets it pnpm, at the version pinned inpackageManager. Cloudflare's defaults arenpm run buildandnpx wrangler deploy— replace both.npx wranglerwould in fact work (it resolves the local binary fromnode_modules/.bin), but it silently falls back to fetching wrangler from the registry if that binary is ever missing, wherepnpm execfails loudly instead.If Workers Builds ever does run
npm install, thepreinstallguard fails the build with an explicit "use pnpm" error rather than quietly producing a second lockfile. -
Point the apex domain at the Worker. Settings → Domains & Routes → add
bgever.comas a custom domain. Cloudflare creates the proxied DNS record itself. -
Redirect
wwwto the apex. Rules → Redirect Rules → Create:- When —
Hostname equals www.bgever.com - Then — Dynamic redirect,
concat("https://bgever.com", http.request.uri.path), status 301, preserve query string on
This has to be a Redirect Rule rather than Worker code: on the
wwwhostname a request for/would matchindex.htmland be served by the static-asset layer before the Worker ran, so a redirect inworker/index.tswould never fire. Redirect Rules run earlier, cost nothing, and are free on every plan. - When —
-
Enable Web Analytics. Analytics & Logs → Web Analytics → add
bgever.com. Because the zone is proxied this needs no script tag, which is why there is no analytics code here.
Handled in worker/index.ts and covered by tests in tests/redirects.test.ts:
| Old URL | Goes to | Why |
|---|---|---|
/blog |
/ (301) |
The post index moved to the site root |
/contact |
/about (301) |
The contact page and its form are gone |
/admin/* |
404 | Netlify CMS is gone and must not resolve |
www.bgever.com/* |
bgever.com/* (301) |
Redirect Rule, not the Worker — see above |
Old post URLs such as /2021/hello-world-again/ need no redirect: the path is unchanged, and
Cloudflare's auto-trailing-slash handling 301s the trailing-slash form to the canonical one.