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.
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.
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
╌╌ END ╌╌