How this site works

This is the story behind the page you're reading it on. Not the content - the machinery. How it's built, how it deploys, where everything lives, and why it's put together the way it is.

I've written about my site before. This is the current state.


The architecture in one diagram

The site is split across two separate Cloudflare Pages deployments and a thin Worker proxy layer:

main site  ──→  [this website]  (Cloudflare Pages)
                       │
        ┌──────────────┼──────────────┐
        │              │              │
/api/diagrams  /api/screenshots  /api/archives
        │              │              │
    ┌───┴──────────────┴──────────────┴───┐
    │           Cloudflare Workers         │
    │        3 proxy endpoints, 1yr cache  │
    └───┬──────────────┬──────────────┬───┘
        │              │              │
  content origin ──→  ******.pages.dev

The main site and the content origin are independent Pages projects. The Workers sit between them - they proxy content from the origin, add long cache headers, and guard against path traversal. There are three endpoints: diagrams, screenshots, and archives. (A fourth, markdown, served post bodies to the old client-side renderer - it was retired once posts became prerendered.)


The storage decision that wasn't

I nearly replaced the content origin with a Backblaze B2 bucket because I'd misread the Cloudflare Pages docs - I thought the 25 MB limit applied to total deployment size, not individual file size. The content origin accumulates blog posts, diagrams, and screenshots indefinitely. If the cap was on total size, I'd hit it eventually.

The Worker proxies made the migration trivial on paper: each one builds an upstream URL from the request path, so switching ******.pages.dev for a B2 endpoint is a one-string change per Worker. The browser URLs wouldn't change. The deployment tooling would move from git push to b2 sync, but that's a CI change, not an architectural one.

I had the migration mapped out when I re-read the actual limits - 25 MiB per asset, 20,000 files on Free. No total size limit. The largest file this site serves is dependabot-parser-tests.7z at 415 KB. I wasn't close. I won't be for a long time.

The architecture stayed the same.

The investigation validated something useful: the Worker proxy layer decouples the origin from the rest of the stack. If the origin ever needs to change - cost, scale, platform policy - it's a URL swap in three files. The browser never knows.


No framework, a one-line build

The site is a vanilla SPA - single index.html, clean URLs, zero dependencies at runtime. Even blog posts cost no JavaScript to read - they're rendered ahead of time.

<div class="wrap">
  <section id="page-home" class="page">...</section>
  <section id="page-blog" class="page" style="display:none">...</section>
  <section id="page-post" class="page" style="display:none">...</section>
  <section id="page-projects" class="page" style="display:none">...</section>
  <section id="page-skills" class="page" style="display:none">...</section>
  <section id="page-work" class="page" style="display:none">...</section>
</div>

Each "page" is a <section>. The router in app.js shows and hides them based on window.location.pathname, and every route is backed by a directory in dist/ - the CDN serves the HTML for it directly. No server code, no redirect rules, no framework overhead. There is no nav bar at all: the kbd keys (h b p s w, t for theme) are the interface, with a hint line at the bottom.

Source vs production

In the repo, main/ is the source of truth - unminified files, commented, readable. The browser never loads them directly. A build script (.scripts/build.js, run with build.bat or build.sh) bundles, minifies, and prerenders everything into dist/:

dist/
├── index.html
├── 404.html
├── _headers
├── functions/api/   (3 Workers)
├── blog/
│   ├── index.html            (list page)
│   └── portable-python/index.html   (one dir per post - the article is already in there)
├── projects/, skills/, work/ (page shells)
└── assets/
    ├── guava.min.css    (4 css files, 23.6 kB -> 15.9 kB)
    ├── guava.min.js     (3 js files, 19.5 kB -> 12.0 kB)
    └── fonts/           (2 woff2, Geist Mono variable pair)

What actually gets served:

<link rel="stylesheet" href="/assets/guava.min.css" />

<script id="site-config">window.SITE={...};</script>
<script id="slug-data">window.POSTS=[...];window.PROJECTS=[...];...</script>
<script src="/assets/guava.min.js"></script>

esbuild does the bundling - the four CSS files concatenate in order (theme, base, markdown, hljs), the three scripts in dependency order (posts, pages, app). Both get minified in the same pass. The two files that were historically the load-sequence problem - entrypoint.css with its @import waterfall, entrypoint.js with its document.write() - are long gone; the bundles are the direct-tag approach taken to its conclusion: two requests for all application code. Prose renders in Geist Mono (variable pair, self-hosted); code blocks fall back to the system monospace stack so the two read distinctly.

The data layer

Five INI files in main/slugs/ - plus meta/posts, which the build generates from post frontmatter - hold the site's index data. The build bakes them straight into index.html as a <script id="slug-data"> setting window.POSTS / PROJECTS / SKILLS / WORK / PROFILE, so no data file is fetched and nothing is parsed in the browser:

slugs/
├── profile/
│   ├── about.ini      # name, sub, bio (key=value, no sections)
│   ├── skills.ini     # skill list by category ([category], items as bare lines or name=href)
│   └── socials.ini    # social links with inline SVGs ([platform], href=, svg=)
└── meta/
    ├── projects.ini   # project list ([title], role=, tags=, href=, description=)
    └── work.ini       # work history ([title], role=, description=)

The blog index is generated from the frontmatter in posts/*.md, so the filename and the index can't drift apart.

The prerendered page shells (/blog/, /projects/, /skills/, /work/, every post) inherit the same inline data, since they are all cut from the finished index.html - so a direct load or refresh on any route has the data without an extra request. The raw slugs/ sources never reach dist/. The same goes for window.SITE - page toggles, theme default and home counts baked in from config.json.


Blog posts: rendered at build time

Nothing about a post is fetched when you open it. Every post is rendered to HTML during the build:

  1. Discover - frontmatter.js scans posts/*.md. The filename is the slug; the frontmatter block holds the title and a raw ISO date. Posts sort newest-first straight off that date - there is no index file to keep in sync.
  2. Parse - marked converts the markdown to HTML, Node-side.
  3. Highlight - highlight.js colorizes code blocks before anyone visits. The vendor scripts run in the build, not in the browser.
  4. Link chrome - external links get target="_blank", text links get wrapped in the span their hover underline needs.
  5. Emit - the article is written into the page shell as dist/blog/<slug>/index.html, with its title, date, and reading time baked in.

Opening a post is then just loading a file: the CDN serves prerendered HTML. No markdown fetch, no <script> injection, no client-side parse. The /api/markdown Worker has since been deleted.

What the client does add when a post arrives: title bars with copy buttons on every code block, [!NOTE] callouts, and image flag syntax (alt|position|size) upgraded in the DOM. Moving between pages stays snappy because each target's HTML is fetched once and cached in memory - clicks swap the page contents without a full reload. Routes you haven't visited yet prefetch quietly once the browser is idle. Posts get the same treatment on intent: hovering or tabbing onto a blog row starts its fetch early, and on touch devices rows warm as they scroll into view - by the time you tap, the fetch is usually done.

The proxy endpoint

Each Worker endpoint is roughly 40 lines:

export async function onRequest(context) {
  const path = context.params.path

  if (!path || path.includes("..") || path.includes("\\")) {
    return new Response("Invalid path", { status: 400 })
  }

  const upstream = `https://******.pages.dev/blogs/${path}`

  const response = await fetch(upstream, {
    cf: { cacheEverything: true, cacheTtl: 31536000 },
  })

  if (!response.ok) return new Response("Not found", { status: 404 })

  const headers = new Headers(response.headers)
  headers.set("Cache-Control", "public, max-age=31536000, immutable")
  headers.set("X-Content-Type-Options", "nosniff")

  return new Response(response.body, { status: response.status, headers })
}

The same pattern is used for diagrams (/api/diagrams/), screenshots (/api/screenshots/), and archives (/api/archives/). Each proxies to a different subdirectory on the content origin.


Diagrams: Graphviz dot → SVG

Source diagrams live in files/images/.rawdot/*.dot and are rendered to diagrams/*.svg with a batch script in files/.scripts/:

@echo off
setlocal

set "BASE=%~dp0..\images"
set "SOURCE=%BASE%\.rawdot"
set "DEST=%BASE%\diagrams"

if not exist "%DEST%" mkdir "%DEST%"

for %%f in ("%SOURCE%\*.dot") do (
    echo Rendering %%~nxf
    dot -Tsvg "%%f" -o "%DEST%\%%~nf.svg"
)

The output SVGs are served through the Worker proxy with the same year-long cache headers. The .dot sources are checked into the repo so diagrams remain editable.


Performance by the numbers

Initial load (no post content)

Category Requests Size
HTML 1 ~11 KB (data inlined)
CSS 1 ~15 KB (guava.min.css)
JS 1 ~12 KB (guava.min.js)
Fonts 2 ~147 KB
Favicon 1 --
Total 6 ~38 KB + fonts

Down from 23 at the worst point. The CSS is 4 source files behind 1 bundle (hljs.css kept separate for the syntax palette); the JS 3 files -> 1 bundle; the slugs 6 groups -> 0 requests, inlined. Four source files or forty, the browser only ever sees two bundles and one self-contained index file.

Caching strategy

Set at the edge via _headers (Cloudflare Pages feature) and Worker response headers:

Path Policy
/assets/fonts/* max-age=31536000, immutable
/assets/* no-cache (revalidates on deploy)
/api/diagrams/* max-age=31536000, immutable (proxy)
/api/screenshots/* max-age=31536000, immutable (proxy)
/api/archives/* max-age=31536000, immutable (proxy)

The bundles are immutable-ish by convention: the build is so cheap that a deploy always ships them fresh, and the no-cache on /assets/* makes sure browsers revalidate.

Then vs now

Aspect Before optimization After optimization
CSS loading @import in entrypoint.css (sequential waterfall) 1 bundle: guava.min.css (built, minified)
JS loading document.write via entrypoint.js (non-speculative) 1 bundle: guava.min.js (built, minified)
Data loading 7 sync XHR (sequential, blocking main thread) 0 requests: inlined in index.html at build time
Data format JSON (*.json), one XHR each INI sources, baked into the html
Vendor scripts Eager link rel preload on home/blog visit Rendered into the HTML at build time - no vendor JS shipped
Build step None (source was served) build.bat / build.sh -> dist/
Total requests 23 6

The things that aren't obvious

Keyboard shortcuts

Every page has a hidden navigation layer:

Key Action
h Home
b Blog
p Projects
s Skills
w Work
t Theme

The <kbd> hints at the bottom of each page aren't decorative - they're the actual interface.

Theme

Dark unless config.json says otherwise, light mode saved to localStorage. The inline <script> in <head> applies the saved choice - or the configured default - before the page paints to avoid a flash:

(function() {
  var t;
  try { t = localStorage.getItem('theme'); } catch (_) {}
  if (t !== 'light' && t !== 'dark') t = 'dark';
  if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
})();

Not every browser will let you have that localStorage - strict privacy modes, sandboxed iframes, and storage-disabled settings all throw on access. app.js probes it once at startup with a test write. If the probe fails, the site swaps in an in-memory stand-in: theme switching still works for the session, it just won't survive a reload. The only trace is a console message, logged after a delay so it doesn't shout over early-page noise:

try {
  localStorage.setItem('__test__', '1');
  localStorage.removeItem('__test__');
  storage = localStorage;
} catch (_) {
  // in-memory fallback: theme works, persistence doesn't
  storage = {
    getItem: function() { return null; },
    setItem: function() {},
  };
}

The loader screen

The >_ boot prompt with the blinking cursor is purely cosmetic. This site runs with it off - loaderEnable: false in config.json, so you land straight on the page. Switch it on and it holds until the page loads past a floor of defaultDuration (1200 ms out of the box), then fades out with a CSS transition and sets display: none.

The title animation

The landing name types itself in one character at a time, 150 ms a step, with the cursor riding after the last letter - solid while it types, blinking again once it settles. It replays on every page load (and only on the landing page); prefers-reduced-motion fills the name in instantly.

404 page

Minimal and self-contained. No JS framework, no external dependencies. Press h to go home. Theme is applied from localStorage inline.


Where it lives

mysite/
├── .scripts/        → build pipeline (build.js, validate.js, prerender.js, frontmatter.js, slugs.js, config.js)
├── config.json      → site toggles (bootloader, theme, pages)
├── posts/           → markdown posts (frontmatter: title + ISO date, filename = slug)
├── build.bat        → windows entry point
├── build.sh         → linux/macOS entry point
├── main/            → source of truth (unminified)
│   ├── index.html
│   ├── 404.html
│   ├── _headers
│   ├── assets/
│   │   ├── css/     (4 files, incl. hljs.css)
│   │   ├── js/      (3 files)
│   │   ├── fonts/   (2 woff2, Geist Mono variable pair)
│   │   └── vendor/  (marked + highlight, build-time only, never shipped)
│   ├── slugs/       (2 dirs, 5 ini files)
│   ├── functions/api/  (3 Workers)
│   └── wrangler.toml
├── dist/            → built output (bundled, minified, prerendered)
│   ├── index.html, 404.html, _headers
│   ├── assets/      (guava.min.css, guava.min.js, fonts/)
│   ├── blog/        (list page + one dir per post)
│   ├── projects/, skills/, work/
│   └── functions/api/
└── files/           → ******.pages.dev
    ├── _headers
    ├── .scripts/    (convert.bat, mogrify.bat)
    ├── archives/    (7z archives)
    └── images/
        ├── .rawdot/   (Graphviz sources)
        ├── diagrams/  (rendered SVGs)
        └── screenshots/

The only dependency is esbuild, as a devDependency inside .scripts/ - the site itself still has no runtime dependencies and no framework. Deploy is wrangler pages deploy dist --branch=<production-branch> from the repo root (pages deploy alone only makes preview builds), which also picks up the Workers from functions/.


What would probably improve the site further

  1. Version the bundles - hash the filenames (guava.min.abc123.css) so /assets/* can be immutable instead of no-cache.

But for a site with one devDependency and no framework, ~38 KB and 6 requests is well inside "fast enough." The page is interactive in under a second on a decent connection, and the blog content - the heaviest part - only loads when you ask for it.