Design / Web/Blog
21 / 67

08/2023Design / Web

Blog

╌╌╌╌

A ground-up redesign of my personal blog, with a new domain to go with it at amittai.space. It is built with Nuxt 4 (Vue) and Sass on Bun, statically generated and deployed on Netlify.

The content compiles through a build pipeline rather than sitting as a folder of HTML. Posts are Markdown in a Nuxt Content collection; build-time hooks rewrite each file before parsing (display-math tikzpicture blocks become fenced tikz, Markdown quotes become typographic ones), then every TikZ block is rendered to SVG with node-tikzjax, math runs through rehype-katex against a custom macro layer, and code is highlighted by Shiki. Parsed pages land in a SQLite store (better-sqlite3), each keeping its post-transform rawbody; nuxi generate then crawls the link graph and freezes the site to static files, prerendering RSS, Atom, and JSON feeds, a sitemap, an llms.txt, and a /raw/*.md route off the same store.

A live layer sits on top of the otherwise static pages. The browser opens one WebSocket, once, via a useSocket singleton to a companion Rust service (Actix-Web + MongoDB) at api.amittai.studio/connect. Every frame is tagged with a scope, and each store subscribes only to the scopes it cares about; the connection queues messages while offline and reconnects on its own. Four features ride it.

Comments

Comments are written in a TipTap editor that emits two fields, text (plain) and markup (pre-rendered HTML). A new comment goes up as a comments-scope create frame. The service parses the scope, writes a BlogComment into MongoDB's comments collection — stamping created_time, zeroing likes, and, for a reply, pushing the new id into the parent's replies array — then publishes a CommentEvent on a Tokio broadcast channel. The socket forwards that event to a peer only when the peer's active path matches the comment's page; that path is set by a separate watch frame and is the same filter that gates live view counts. Replies are stored flat (each document keeps a reply_to pointer and a replies id list) and reassembled into a nested tree in memory on read. Private notes are comments with is_private set: the list query returns public comments plus the caller's own private ones.

A comment — and a highlight, which is just a comment carrying a text anchor — travels clockwise: up the single WebSocket as a scoped frame, through the scope router into the comments collection, and back out on a Tokio broadcast channel. The socket forwards the event only to peers whose watched path matches the comment's page.

Inline highlights

A highlight is a comment, not a separate record. When you select text, the anchor is serialized into the comment's markup string as scope:index|quoted text followed by an occurrence index and the words on either side, joined by ASCII unit separators. No DOM offsets are stored, so the anchor survives a re-render. On load, applyHighlights groups the page's comments by markup, normalizes the article's text, and searches for each quote inside .content-container; when a quote appears more than once it disambiguates first by the saved context words, then by the occurrence index, and wraps the matched range in a <mark> with a margin annotation. Because highlights are comments, they arrive over the same socket and light up live for everyone on the same page. A highlight can also be shared as a link: shareMarkup packs the anchor into a ?hl=...&by=... query, and the recipient's page finds the text and marks it green with the sharer's name on hover.

Now playing

The dynamic island carries two independent things. Owner-published status slots — watching, reading, working_on, status — live in the now scope: each edit is written to a MongoDB now collection with a TTL on expires_at, and the write broadcasts a NowEvent to every connected client, so all islands update at once. Music is separate. The playback scope is request-and-reply, on demand: when the page asks for the current track, the service spawns a task that calls the Spotify Web API through SpotifyClient. That client holds an OAuth token, refreshing it against accounts.spotify.com with a stored refresh token, then hits me/player/currently-playing; a 204 (nothing playing) falls back to the most recent track from recently-played. Spotify dropped preview_url from that response, so the client scrapes each track's /embed/ page for the audioPreview URL. Nothing is polled server-side and there are no webhooks; the reply returns only to the client that asked.

The island runs two scopes side by side. The playback branch is request-and-reply: a spawned task calls the Spotify Web API through SpotifyClient (refreshing its OAuth token on the way), and the track comes back only to the client that asked. The now branch is broadcast: owner status slots persist to a TTL collection and the NowEvent fans out to every connected client at once.

View counts

View counts are never requested directly. When a client's watch path changes, the service atomically increments that route's document in the views collection and broadcasts the new total; a client sees the update only for the page it is on, with the archive view the one exception, which receives every route's count. A second counter tracks presence: an atomic client tally moves on each connect and disconnect and is broadcast to all sockets as a live reader count. A REST-plus-SSE /views/ route, backed by a MongoDB change stream with a heartbeat, mirrors the same data for consumers that are not on the socket.

The visual language follows minimalism: a restrained type scale, generous whitespace, few colors. Type, color, and spacing rules live in shared Sass partials rather than per-component scopes, so a single change propagates everywhere, and a light and dark color mode plus a stricter "Rams mode" toggle (applied before first paint to avoid a flash) sit on top. The move to amittai.space was the occasion to retire the old design entirely rather than patch it, so nothing carried over except the writing.

References

  1. Project repository
  2. Live site

╌╌ END ╌╌