Project

alexanderk.dev

This website

★ ---- commits--updated --

This website. Built with Astro into plain HTML, CSS and JavaScript, and hosted on Cloudflare.

  • Astro
  • HTML
  • CSS
  • JavaScript
  • GitHub API
  • Cloudflare

README

Loading README from GitHub…

Walkthrough

How this site works, from the files you see in the repo to the live GitHub numbers. You'll want to know basic HTML, CSS and JavaScript going in.

1. The big picture

What your browser downloads is plain files: HTML pages, one CSS file and a few JavaScript files. There's no framework (like React) running in the browser.

The pages are written with Astro, a static site generator. The header, footer and page shell live in one layout file, and each project and research post is an entry in a small data file. When the site is built, Astro stitches those together into one finished HTML file per page. So a change to the header is one edit, not twenty.

The one exception is a small script that runs on Cloudflare and keeps the GitHub numbers up to date. Here's how a visit works:

your browser ──asks for a page──► Cloudflare ──► the HTML, CSS and JS files
     │
     └─asks for /data/github.json─► Cloudflare script ──► saved GitHub data
                                         ▲
                       every 5 minutes: asks GitHub for fresh numbers

2. The files

FileWhat it does
src/layouts/The page shell (head, header, footer) and the project and post layouts
src/pages/One file per page: About, Projects, Research, Stats
src/data/The list of projects and research posts that the pages and cards are built from
public/css/style.cssAll the styling for every page
public/js/theme.jsLight and dark mode
public/js/gh.js, github.js, stats.jsFill in the GitHub numbers, READMEs and the Stats page
worker/index.jsThe Cloudflare script that fetches from GitHub
wrangler.jsonc, astro.config.mjsCloudflare and Astro settings

3. Styling and dark mode

Every color, space and font size is defined once, as a CSS variable at the top of style.css. The rest of the stylesheet only uses the variables. Dark mode is then just a second set of values for the same variables:

:root      { --bg: #eeeeec; --text: #2e3436; }   /* light */
:root.dark { --bg: #2e3436; --text: #eeeeec; }   /* dark */

body { background: var(--bg); color: var(--text); }

Switching themes means adding or removing one class, dark, on the page. theme.js does that. It runs before the page is drawn, so there's no flash of the wrong theme. It uses the theme you picked with the toggle if you've picked one, and otherwise follows your device's light or dark setting:

const saved = localStorage.getItem("theme");          // "dark", "light" or nothing
const deviceIsDark = matchMedia("(prefers-color-scheme: dark)").matches;
const theme = saved || (deviceIsDark ? "dark" : "light");
document.documentElement.classList.toggle("dark", theme === "dark");

The colors come from Tango, the palette used by GNOME and most Linux terminals.

4. Live GitHub numbers

The stars, commits and languages on the project cards come from GitHub. The obvious approach is to have each visitor's browser ask GitHub directly. The catch: GitHub only allows 60 requests an hour per visitor without a key, and the Stats page alone needs dozens. Reload a few times and the numbers stop loading.

The fix is to have one program ask GitHub on everyone's behalf and share the answer. That program is a Cloudflare Worker, a small script that runs on Cloudflare's servers. Cloudflare starts it every 5 minutes:

// wrangler.jsonc
"triggers": { "crons": ["*/5 * * * *"] }

Each run, the Worker:

  1. Asks GitHub for every repo's stars, commits, languages and last update, all in one request (a GraphQL query).
  2. Gets recent activity, plus any README that changed since last time.
  3. Saves everything as one JSON file in Cloudflare's storage (KV), but only if something changed.

When a browser asks for /data/github.json, the Worker hands back that saved file. Every other address is a normal file:

async fetch(request, env) {
  const url = new URL(request.url);
  // any other address: a normal file
  if (url.pathname !== "/data/github.json") return env.ASSETS.fetch(request);
  // the data file: the saved GitHub data
  const body = await env.GITHUB_DATA.get("github.json");
  return new Response(body, { headers: { "Content-Type": "application/json" } });
}

On the pages, each number starts as a placeholder that says which repo and which number it wants:

<b data-gh="amkashyap1220/SwitchToYoutube" data-field="stars">--</b>

github.js finds every placeholder, looks the repo up in the data file, and fills in the number:

document.querySelectorAll("[data-gh]").forEach((el) => {
  repo(el.dataset.gh).then((data) => {
    el.textContent = data[el.dataset.field];
  });
});

Now visitors' browsers never talk to GitHub at all, and the numbers are never more than about 5 minutes old.

5. The Stats page is a small data pipeline

stats.js is organized like an ETL job, the extract-transform-load pattern data engineers use everywhere:

  • Extract: read the repos, languages and activity from the data file.
  • Transform: add up totals, like bytes of code per language across every repo, or the busiest weekday.
  • Load: draw the results: the number tiles, the language bars and the activity summary.

For example, here's the transform step that totals up languages:

function languageTotals(repos) {
  const totals = {};
  repos.forEach((r) => {
    Object.entries(r.languages).forEach(([lang, bytes]) => {
      totals[lang] = totals[lang] || { name: lang, bytes: 0, repos: 0 };
      totals[lang].bytes += bytes;    // how much code
      totals[lang].repos += 1;        // how many repos use it
    });
  });
  return Object.values(totals);
}

6. Shipping changes

The repo is connected to Cloudflare, so every push to main deploys the site automatically. Cloudflare runs npx wrangler deploy, which builds the pages with Astro and uploads them along with the Worker. It's live in under a minute.

Changes don't go straight to main, though. Each one goes on its own branch with a pull request. Cloudflare builds a preview of the branch, and it gets merged once it looks right:

git checkout -b add-new-project     # new branch
# ...make changes...
git push -u origin add-new-project  # Cloudflare builds a preview
# open a pull request, check the preview, merge → live