The client side
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 inlocalStorageand 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 declaredpassiveso the browser never waits to see whether it cancels the scroll. It asksprefers-reduced-motionat 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-urlordata-bluesky-uri-- exactly one network per site, seeSiteConfig.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 leavescomments.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, inlib/post_stats.rband incomments.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 viaBlog.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'son-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 fromwindow.BLOG_I18Nlike 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.mastodonhas 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.copywrites the address to the clipboard and renames itself while it says so.systemhands 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 everycodeblock -- 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 withoutnavigator.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.htmlis 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 thanunsafe-inline.
