The client side

Architecture — part 12 of 13

Small single-purpose vanilla JS files, no framework, no third-party origins. util.js loads first because everything else stands on it: Blog.escapeHtml, which every piece of foreign data goes through before it reaches innerHTML, and Blog.formatDate, which takes its locale from window.BLOG_I18N so a date the browser writes matches the ones the build wrote. Then, in load order:

  • Theme (theme-toggle.js): a button cycling three states -- follow the system, light, dark -- with the reader's answer in localStorage and nothing there while they are following the system. Anything else found in storage is read as "no choice" rather than written onto the root element, where it would match neither the light rules nor the dark ones.
  • The menu on a phone (nav-toggle.js): opens and closes the bar, and closes it on Escape or a tap on the page as well as on the button. The open menu covers most of a phone's screen, and a reader who has decided against it reaches for one of those two before hunting for the small button again; Escape hands focus back to the button, which is where they were before they opened it.
  • Back to top (scroll-top.js): the button appears past 300 px of scrolling, on a listener declared passive so the browser never waits to see whether it cancels the scroll. It asks prefers-reduced-motion at the click rather than at load, because the setting can change while the page is open -- and it has to ask at all, since CSS can take the site's transitions out but not a scroll this code asks for.
  • Comments (comments.js): a published post's announcement reference is baked into the page (data-toot-url or data-bluesky-uri -- exactly one network per site, see SiteConfig.comment_network). Reading it in the browser needs a server that serves a thread to an anonymous request; GoToSocial requires a token there and so does Mastodon in secure mode, which leaves comments.approval (a cron with a token) as the only way in. The address itself comes in three shapes -- Mastodon's /@user/<id>, GoToSocial's /@user/statuses/<ULID> and the ActivityPub /users/user/statuses/<id> -- read by one rule written twice, in lib/post_stats.rb and in comments.js, most specific pattern first. Then the visitor's browser fetches the reply thread from the Mastodon instance's public context API or from Bluesky's public AppView (getPostThread, no auth). Everything except Mastodon's own sanitized status HTML is escaped via Blog.escapeHtml; Bluesky reply text is plain text and is escaped wholesale.
  • Widgets (sidebar.js) read same-origin JSON (toots.json, bluesky.json, pixelfed.json, commits.json, rss.json, stats.json, and each language tree's on-this-day.json) that cron (scripts/refresh_sidebar.rb) fetched server-side -- the visitor's browser never contacts GitHub, the Fediverse or Bluesky for them. A failed cron fetch keeps the previous JSON rather than publishing an empty widget.
  • Search (search.js) runs entirely client-side over the two index files, with the same diacritic folding the build used to create them.
  • Lightbox (lightbox.js) opens a post's figures and gallery images over the page as a real dialog: aria-modal, the body held still, Tab kept inside it, the arrows walking the group the image belongs to, and focus handed back to the picture it was opened from. Its labels come from window.BLOG_I18N like every other string the client draws.
  • The tag index (tag-index.js) reorders /tag/ between alphabetical and by-count, and remembers which the reader chose. The page is built alphabetically, so a reader without the script gets the order the markup already has rather than a control that does nothing. The row of letters above the list links to the bands between the tags; count order takes the bands out, so the script hides the row with them.
  • Sharing (share.js) drives the three share controls a link cannot be. mastodon has no single address to point at -- the destination is the reader's own instance -- so it asks once and remembers the answer in that browser, refusing anything that is not a hostname rather than pasting it into an address. copy writes the address to the clipboard and renames itself while it says so. system hands the post to the operating system's share sheet. All three of those arrive HIDDEN and are shown only where they can work -- the fediverse button once its script has run, the sheet where the browser has one, the clipboard button where there is a clipboard, which browsers grant over https alone. That is the same promise the copy button below makes and for the same reason. A block left with nothing it can draw hides itself rather than standing a heading over an empty row.
  • Copy (copy-code.js) puts a copy button on every code block -- built in the script rather than in the markup, so a page whose script never runs shows no button instead of a dead one. It refuses to appear at all where the clipboard is unavailable: an insecure origin, or a browser without navigator.clipboard.writeText.
  • The 404 page (not-found.js) is the one script a single page asks for by itself rather than through the layout: /404.html is one file for every missing address, and only the browser showing it knows which. It reads the address in the bar, puts its words into the page's search field -- the last part that holds a letter, separators turned to spaces -- and shows the archive pill of a year the address names. It searches nothing and redirects nowhere; without it the page is an empty field and the pills that are always there.
  • i18n: locale strings the client needs are embedded once per page as window.BLOG_I18N -- the only inline script, allowlisted in the CSP by its SHA-256 content hash rather than unsafe-inline.