Personal blog and portfolio at tbd.codes, hosted on GitHub Pages.
Built entirely in vanilla HTML, CSS, and JavaScript — no frameworks, no build step, no bundler. This is an intentional constraint: the site stays fast, easy to inspect, and fully understandable without tooling.
| Concern | Approach |
|---|---|
| Blog posts | Markdown files with YAML frontmatter, rendered client-side via marked |
| Post index | Auto-generated posts/index.json from a Python script; synced daily from a private Obsidian vault via GitHub Actions |
| Comments | Google Forms → Sheets → gviz/tq API (no backend required) |
| Emoji reactions | Cloudflare Workers KV (cloudflare/reactions-worker.js) |
| View counter | Cloudflare Workers KV (cloudflare/view-counter-worker.js) |
| Push notifications | OneSignal Web Push SDK; triggered by GitHub Actions on new post sync |
| Travel map | Leaflet.js + location data from Google Takeout, with manual overrides |
| Header / footer | Injected at runtime from components/ via js/include-components.js |
No build step needed. Open any HTML file directly in a browser, or run a local server:
python -m http.serverTo regenerate the post index after editing markdown files:
pip install pyyaml
python scripts/generate_posts_index.py- Reactions bar on every post with default set (❤️ 👍 🔥 🤔) and live counts
+button opens an inline text input; validated viaIntl.Segmenterto ensure only valid emoji are accepted — uses the native OS emoji keyboard on mobile- Counts stored in Cloudflare Workers KV (dedicated
tbd-reactionsworker, separate from the view counter) - Read: single
GET /reactions/{slug}returns a{ emoji: count }object for just that post - Write:
POST /reactions/{slug}/{emoji}increments the count; optimistic UI updates immediately - No user tracking — counts are the source of truth, no localStorage state
- View count shown in post metadata for full posts and (if > 0) on preview cards
- Powered by a self-hosted Cloudflare Worker — no third-party tracking services
- Full post load increments via
GET /{slug}/up; previews read without incrementing viaGET /{slug} sessionStorageprevents the counter from incrementing on every refresh within the same browser session
- Subscribe / Unsubscribe button in the blog sidebar via OneSignal Web Push
- Button is hidden until the SDK initialises — avoids a broken UI for users whose browser blocks the SDK (e.g. Firefox Enhanced Tracking Protection)
- GitHub Actions automatically sends a push notification to all subscribers when new posts land:
- New posts detected via
git diff --cached --diff-filter=Abefore commit - Notification content derived from post YAML frontmatter
- Only
ONESIGNAL_API_KEYis stored as a secret; App ID is read fromjs/notifications.jsat runtime
- New posts detected via
- Category counts: filter buttons show post count, e.g. "Travel (12)"
- Share button: uses
navigator.share()on mobile; falls back to copying URL to clipboard on desktop - Scroll-to-top: fixed ↑ button appears after scrolling down the post list
- Scroll restoration: post list returns to the previous scroll position after navigating back from a post
- Recent posts widget: home page shows the 5 most recent posts with dates (
js/recent-posts.js) - Lazy images: all dynamically injected post images get
loading="lazy" - Auto footer year: updated via JS so it never goes stale
- Infinite scroll: replaced paginated post list with continuous scroll
- Smarter previews: preview text is now the first paragraph of the post rather than a fixed character slice
- Swipe navigation: swipe left/right between posts on touch devices
- Image lightbox: tap any post image to view it full-screen; Escape to close
- Sticky post title: post title sticks to the top of the scroll container while reading
- TOC anchor links: headings in the table of contents link to the correct section with matching IDs
- Table scroll wrapper: wide tables scroll horizontally on mobile instead of overflowing
- Reading progress bar: thin accent-coloured bar at the top of the post container
- Terminal boot animation: home page plays a boot sequence on first load
- Custom 404 page: themed to match the site aesthetic
- Open Graph + Twitter meta tags: per-post share previews with title, description, and image
- RSS feed: auto-generated at
/feed.xml - Current location pin: travel map shows a distinct pin for the current location
- Blog: client-side Markdown rendering via
marked; category filtering; full-text search; prev/next post navigation; table of contents; URL-based deep linking into posts - Comment system: Google Forms → Sheets → gviz/tq, rendered client-side with no backend
- Unread notifications: cookie-based tracking of last-read timestamp per post
- CRT screen effect: scanline overlay toggled via a cookie, persisted across sessions
- Travel map: Leaflet.js map with posts plotted by location; colour-coded by country; location data sourced from Google Takeout with manual override support
- Automated post sync: GitHub Action runs daily to pull new posts from a private Obsidian vault and regenerate
posts/index.json