Pidgin Markup For Writing, or How Much Can HTML Sustain?
Read on the website: HTML is flexible and was shaped by generations of web practitioners. It has enough tricks up its sleeve to actually be nice to author. Here are some.
Read on the website: HTML is flexible and was shaped by generations of web practitioners. It has enough tricks up its sleeve to actually be nice to author. Here are some.
I recently wrote an essay on why you should set up a personal website rather then using social media. Doing so lets you own your space on the internet, customize it and free your readers from constant advertising and algorithmic feeds designed to keep you stuck doomscrolling all day. However, despite how much time we spend using it, creating something for the intenet is seen as arcane wizardy by most people. This is a fairly accessable guide to getting started. You’ll need a text editor (any will do) and a browser (you already have one). All pages are written in HTML, which is a simple text-based format. To start with, this is a perfectly valid HTML document: To try this, just create a text file with a ".html" extension, and open it in your favorite browser. Do this now : experimenting is the best way to learn how everything works. This is what it should look like: Plain text is boring, so let’s add some formatting: The angle bracket things are tags: "<b>" is an opening tag, and "</b>" is the matching closing tag. The word surrounded by brackets ("b") is the tag name, which tells the browser what to do: In this case, b olding the enclosed text. The other formatting tags are <em> em phasis , <u> u nderline , <sub> sub scipt , <sup> sup erscript , <small> small text , <mark> highlight and <del> del eted . You don’t have to memorize this list, but go and try a few out. There’s also <br/> ( br eak), which adds a line break. It’s special because there’s no closing tag: It always immediately closed and can’t contain any text. I like to add a slash after the tag name to indicate this A big wall of text can get quite ugly, so it’s good to break it up with <p> ( p aragraph) tags. Each paragraph will be visually separated from other content on the page: Check out my new site: I have many epic things here. Together, the maching tags and their contents form an an element . Elements can contain other elements, but it’s important that they are closed in the correct order: This is wrong: … but this is fine: Browsers will attempt to render invalid HTML, but the results may not be what you intended: It’s best to make it easy for them. On that topic, it’s good practice to put all your content inside a <body> element which is itself inside a <html> element: Check out my new site: I have many epic things here. This isn’t mandatory, but helps browsers render your page correctly: In the case of an old browser, you don’t want metadata (we’ll add some later) getting confused for page content. Ok, back to text-wall-avoidance: the <ul> and <ol> ( u nordered/ o rdered l ist) tags create, well, lists. Each item should be wraped in <li> tags ( l ist i tem) About this site (unordered): It has epic things ... and is handwritten HTML It uses these tags: (ordered) <html> <body> <p> <ul> and <ol> <li> You can add angle brackets to a page with > (>), < (<) and & (&). These entities will render as the corresponding charater, but won’t form tags. Headings use <h1> ( h eading 1 ) through <h5> ( h eading 5 ), with larger numbers using smaller font sizes: This site has epic things and I wrote it myself. To do: Figure out how to add links. About that. Links are just <a> ( a nchor) tags, but they have something new: an attribute after the tag name but before the bracket. The "href= " attribute sets where the link points to. A lot of other tags can also have attributes: For example, ordered lists with "reverse=true" count backwards. The URL in "href=" can be relative: If linking up multiple pages on the same site, instead of this: … just write this: Images work similarly to links, except that they are self-closing elements like <br/>: Check out this picture of a nebula I took! (If you don’t have a URL for your image, skip to the hosting section to set one up) That’s all the essentials, but there’s a lot of other useful tags. For example <details> creates a dropdown that works with ctrl-f: This is a dropdown with just HTML. It works well with browser features (ctrl-f, fragment identifiers, screen readers, etc) by default. (better usability than 99% of commercial sites!) …but I can’t cover everything without writing a whole book. (The Mozzila docs are a fantastic reference) At this point, you should have something like this: I made this site to write about things I do. More updates soon™ . Here's my picture of the Dumbbell Nebula: Let’s start by giving the page a machine-readable title: Like with <body>, the <head> tag isn’t required, but it is good to include it: Otherwise, any metadata that the browser doesn’t understand might be mistaken for content. The page still looks kinda bad: Text extending the edges of the page isn’t exactly easy to read. It’s not too bad when crammed into my blog, but longer paragraphs will look terrible on large monitors. To fix this, we need to add some style and layout information using the <style> tag: Unlike other tags, the contents of <style> isn’t HTML, but CSS: a whole other langauge embedded within the file. CSS is compoosed of blocks, each begining with a selector to control what gets effected. Here, this is just the name of a tag: "head" The selector is followed by a series of declarations wraped in curly braces. My example only has one: "max-width: 30em;" This caps the width of the element at 30 times the font size: I made this site to write about things I do. More updates soon™ . Here's my picture of the Dumbbell Nebula: The page is looking rather asymetrical, so let’s center the column. For fixed-width elements, this can be done using the "margin" property: I made this site to write about things I do. More updates soon™ . Here's my picture of the Dumbbell Nebula: (For varable width elements, use flexbox for centering and other fancy layouts. A single line of text can be centered with "text-align=center") Personally, I like dark themed sites, so lets change some of the colors: I made this site to write about things I do. More updates soon™ . Here's my picture of the Dumbbell Nebula: The "color" style will carry over to every element inside of the styled tag, so there’s no need to individually change the text-color of every element. However, the links do need to be changed because they override the color by default. That’s it. Everything you need to replicate my blog, minus a few small bits like the sans-serif font, nagivation box, etc. Of course, your website can and should be different: It’s yours . I highly recomend you read some documenation and play around with CSS. There’s also way more to it then I can possbly cover here. Every website you see was created with it, and it even supports animations and basic interactivity . … also, check out your browser’s devtools (ctrl-shift-i): It will have a nice GUI for editing which shows you the result in real time and shows you what’s going on under the hood. If you ever run out of tags, you can just make up your own and style them as needed. As long as the name includes a hypen, it’s guaranteed not to be included in any future version of HTML. The specification even lists <math-α> and <emotion-😍> as allowed custom elements names. I’ve used this heavily on this page: All the example websites aren’t screenshots, they are <fake-frame> elements styled up to look like a browser window. Custom tags are also very handy for styling text: At this point you should have a reasonably nice page ready to put up on the internet. The easiest way to do this is to use a static file hosting service like Github Pages or Cloudflare Pages . Both of these have generous free tiers that should last a very long time. If you don’t like big companies, there are plenty of similar, smaller services. These can be more limited: The popular Neocities charges $5/mo to use a custom domain. Another option is to rent a server ($3-$5/mo) or, if you have good internet, run one yourself. This is by far the most fiddly option: I would not recommend it unless you like playing with computers. All off these (except a server) will give you a subdomain by default. For example, Github Pages will give you your-username .github.io However, I do recommend setting up a custom domain: This will let you switch providers seamlessly should anything happen. All of these will work in a similar way: Upload a file with some name, and it will given a URL with that same name. The one exception is that files called "index.html" will be viewable at the root of the folder they are in. You should put an index.html in the root of your site to serve as the homepage, but apart from that, the organization is up to you. It has epic things ... and is handwritten HTML <html> <body> <ul> and <ol> Ken Shirriff's blog Ken Shirriff's blog Ken Shirriff's blog Ken Shirriff's blog
Turns out you can just port things now. I already attempted this experiment in the summer, but it turned out to be a bit too much for what I had time for. However, things have advanced since. Yesterday I ported MiniJinja (a Rust Jinja2 template engine) to native Go, and I used an agent to do pretty much all of the work. In fact, I barely did anything beyond giving some high-level guidance on how I thought it could be accomplished. In total I probably spent around 45 minutes actively with it. It worked for around 3 hours while I was watching, then another 7 hours alone. This post is a recollection of what happened and what I learned from it. All prompting was done by voice using pi , starting with Opus 4.5 and switching to GPT-5.2 Codex for the long tail of test fixing. MiniJinja is a re-implementation of Jinja2 for Rust. I originally wrote it because I wanted to do a infrastructure automation project in Rust and Jinja was popular for that. The original project didn’t go anywhere, but MiniJinja itself continued being useful for both me and other users. The way MiniJinja is tested is with snapshot tests: inputs and expected outputs, using insta to verify they match. These snapshot tests were what I wanted to use to validate the Go port. My initial prompt asked the agent to figure out how to validate the port. Through that conversation, the agent and I aligned on a path: reuse the existing Rust snapshot tests and port incrementally (lexer -> parser -> runtime). This meant the agent built Go-side tooling to: This resulted in a pretty good harness with a tight feedback loop. The agent had a clear goal (make everything pass) and a progression (lexer -> parser -> runtime). The tight feedback loop mattered particularly at the end where it was about getting details right. Every missing behavior had one or more failing snapshots. I used Pi’s branching feature to structure the session into phases. I rewound back to earlier parts of the session and used the branch switch feature to inform the agent automatically what it had already done. This is similar to compaction, but Pi shows me what it puts into the context. When Pi switches branches it does two things: Without switching branches, I would probably just make new sessions and have more plan files lying around or use something like Amp’s handoff feature which also allows the agent to consult earlier conversations if it needs more information. What was interesting is that the agent went from literal porting to behavioral porting quite quickly. I didn’t steer it away from this as long as the behavior aligned. I let it do this for a few reasons. First, the code base isn’t that large, so I felt I could make adjustments at the end if needed. Letting the agent continue with what was already working felt like the right strategy. Second, it was aligning to idiomatic Go much better this way. For instance, on the runtime it implemented a tree-walking interpreter (not a bytecode interpreter like Rust) and it decided to use Go’s reflection for the value type. I didn’t tell it to do either of these things, but they made more sense than replicating my Rust interpreter design, which was partly motivated by not having a garbage collector or runtime type information. On the other hand, the agent made some changes while making tests pass that I disagreed with. It completely gave up on all the “must fail” tests because the error messages were impossible to replicate perfectly given the runtime differences. So I had to steer it towards fuzzy matching instead. It also wanted to regress behavior I wanted to retain (e.g., exact HTML escaping semantics, or that must return an iterator). I think if I hadn’t steered it there, it might not have made it to completion without going down problematic paths, or I would have lost confidence in the result. Once the major semantic mismatches were fixed, the remaining work was filling in all missing pieces: missing filters and test functions, loop extras, macros, call blocks, etc. Since I wanted to go to bed, I switched to Codex 5.2 and queued up a few “continue making all tests pass if they are not passing yet” prompts, then let it work through compaction. I felt confident enough that the agent could make the rest of the tests pass without guidance once it had the basics covered. This phase ran without supervision overnight. After functional convergence, I asked the agent to document internal functions and reorganize (like moving filters to a separate file). I also asked it to document all functions and filters like in the Rust code base. This was also when I set up CI, release processes, and talked through what was created to come up with some finalizing touches before merging. There are a few things I find interesting here. First: these types of ports are possible now. I know porting was already possible for many months, but it required much more attention. This changes some dynamics. I feel less like technology choices are constrained by ecosystem lock-in. Sure, porting NumPy to Go would be a more involved undertaking, and getting it competitive even more so (years of optimizations in there). But still, it feels like many more libraries can be used now. Second: for me, the value is shifting from the code to the tests and documentation. A good test suite might actually be worth more than the code. That said, this isn’t an argument for keeping tests secret — generating tests with good coverage is also getting easier. However, for keeping code bases in different languages in sync, you need to agree on shared tests, otherwise divergence is inevitable. Lastly, there’s the social dynamic. Once, having people port your code to other languages was something to take pride in. It was a sign of accomplishment — a project was “cool enough” that someone put time into making it available elsewhere. With agents, it doesn’t invoke the same feelings. Will McGugan also called out this change . Lastly, some boring stats for the main session: This did not count the adding of doc strings and smaller fixups. Pi session transcript Narrated video of the porting session Parse Rust’s test input files (which embed settings as JSON headers). Parse the reference insta snapshots and compare output. Maintain a skip-list to temporarily opt out of failing tests. It stays in the same session so I can navigate around, but it makes a new branch off an earlier message. When switching, it adds a summary of what it did as a priming message into where it branched off. I found this quite helpful to avoid the agent doing vision quests from scratch to figure out how far it had already gotten. Agent run duration: 10 hours ( 3 hours supervised) Active human time: ~45 minutes Total messages: 2,698 My prompts: 34 Tool calls: 1,386 Raw API token cost: $60 Total tokens: 2.2 million Models: and for the unattended overnight run
No matter how I look at the list of apps I currently use , whether first-party or third-party, I can’t find anything to change, not a program to replace, not a service to swap for another. I think I am happy with my setup. It feels strange to admit, but somehow, I can’t quite believe it; I must be missing something, something surely can be tweaked. What happens after peak setup? This frustration comes from the fact that looking at new apps, digging into settings, trying new online services, working on how each of these things operates with the others, is one of my favourite hobbies. I mean, a quick glance at the archive of this site will tell you that, not only do I love writing about apps and digital tools, but I love playing with their configurations; I’m like a kid with Lego bricks, building things, taking them apart, and building them again, with a huge smile, in a slightly different and improved way. Now that my application setup appears to be “final”, it feels as though all my toys and Lego bricks are neatly stored away in their respective drawers, sorted by colour, by type, and by size. It’s perfect, and seeing my beautiful collection all nice and tidy like that is a very satisfying sensation, except I’m looking at it seated on the empty floor of my childhood bedroom, alone and bored. What is there to do when nothing needs to be improved? I recently wrote about my HTML and CSS “explorations” with this blog. Satisfied with the results, I think this job is done. The same goes for how Eleventy works on my machine: everything has been optimised , refined, future-proofed (especially Node.js ): nothing to see here! Even the hosting is something I’m very happy with. My only gripe with xmit is that there is no possibility for me to pay for it. The other apps on my Mac — the ones that don’t live in the Terminal like Eleventy, Node.js & npm, and xmit — are also perfect at what they do, and I can’t think of anything better to explore, let alone to use. If this is not your first visit, you already know how I feel about BBEdit . Well, I feel just about the same about NetNewsWire , which is as close to perfection an app can get as far as I’m concerned. It feels part of the OS (even more so than current system apps if I’m being honest), it is stable, it is simple to use, and it runs smoothly on my soon-to-be six-year-old MacBook Air. Being happy with Safari is by far the strongest proof that my setup is final. Using StopTheScript to block JavaScript on most media sites, along with the performance and privacy benefits of using a DNS resolver like Quad9 , has proven to be an efficient way to keep Safari light and responsive, even if my web experience is getting a little more interrupted than I would like, due to all the crap websites throw at first-time visitors these days. Yesterday, I had a look at apps like Yoink , Karabiner Elements , Hazel , and also got a taste of Mullvad Browser , and News Explorer . Some of these apps were tried purely out of curiosity, to see if they would fit right in my “workflow”, others were basically reassurance that my current system and choices were the best I could have made. * 1 Among all the parties involved in this setup, the obvious candidate for a replacement is my Intel-powered MacBook Air. Yet, this old computer is currently in great shape: the recent factory-settings reset I had to do surely helped. But its best feature is not being able to run MacOS Tahoe: stuck to MacOS Sequoia, it’s protecting me from Liquid Glass on the Mac and the “icons in menus everywhere” experience. My personal laptop is a breath of fresh air after spending hours on my work computer running Tahoe. * 2 So, what will be able to make that itch go away? When nothing is broken, don’t fix it, as they say. But surely, there must be something that I’m missing, surely there is a program, somewhere, that would delight me, that would put a smile on my face. I want a new box of Lego bricks, I want to empty my drawers on the floor and see if I can do better. In case you’re wondering, all of these apps are excellent, but not enough to replace what I already use, or to justify adding a new item to my list. For example, Mullvad Browser, like Firefox, isn’t scriptable; News Explorer has more features than NetNewsWire, but is not as polished; Yoink looks incredibly useful, but I prefer my own ways for now, &c. ^ Its replacement will have to wait until the new generation comes out, probably in March; then I can decide on whether I want to stick to the Air family, keep mine a bit longer, or upgrade for a far nicer screen and go with the Pro. ^ In case you’re wondering, all of these apps are excellent, but not enough to replace what I already use, or to justify adding a new item to my list. For example, Mullvad Browser, like Firefox, isn’t scriptable; News Explorer has more features than NetNewsWire, but is not as polished; Yoink looks incredibly useful, but I prefer my own ways for now, &c. ^ Its replacement will have to wait until the new generation comes out, probably in March; then I can decide on whether I want to stick to the Air family, keep mine a bit longer, or upgrade for a far nicer screen and go with the Pro. ^
Abstraction is the cornerstone of modern software engineering. Reusing logic and building higher-level solutions from lower-level building blocks is what makes all the technological wonders around us possible. Imagine if every time anyone wrote a calculator they also had to reinvent floating-point arithmetic and string encoding! In healthy ecosystems dependencies are normal, cheap, and first-class. “Dependency-free” is not a badge of honor. And yet, the web platform has outsourced this fundamental functionality to third-party tooling . As a result, code reuse has become a balancing of tradeoffs that should not have existed in the first place. In NodeJS, you just and reference specifiers straight away in your code. Same in Python, with . Same in Rust with . In healthy ecosystems you don’t ponder how or whether to use dependencies. The ecosystem assumes dependencies are normal, cheap, and first-class . You just install them, use them, and move on. “Dependency-free” is not a badge of honor. Instead, dependency management in the web platform consists of bits and bobs of scattered primitives, with no coherent end-to-end solution . Naturally, bundlers such as Webpack , rollup , and esbuild have picked up the slack, with browserify being the one that started it all, in 2012. There is nothing wrong with bundlers when used as a performance optimization to minimize waterfall effects and overhead from too many HTTP requests. You know, what a bundler is supposed to do. It is okay to require advanced tools for advanced needs , and performance optimization is generally an advanced use case. Same for most other things bundlers and build tools are used for, such as strong typing, linting, or transpiling. All of these are needs that come much later than dependency management, both in a programmer’s learning journey, as well as in a project’s development lifecycle. Dependency management is such a basic and ubiquitous need, it should be a part of the platform, decoupled from bundling. Requiring advanced tools for basic needs is a textbook usability cliff . In other ecosystems, optimizations happen (and are learned) after dependency resolution. On the web, optimization is the price of admission! This is not normal. Bundlers have become so ubiquitous that most JS developers cannot even imagine deploying code without them. READMEs are written assuming a bundler, without even mentioning the assumption. It’s just how JS is consumed. My heart breaks for the newbie trying to use a drag and drop library, only to get mysterious errors about specifiers that failed to resolve. However, bundling is not technically a necessary step of dependency management. Importing files through URLs is natively supported in every browser, via ESM imports. HTTP/2 makes importing multiple small files far more reasonable than it used to be — at least from a connection overhead perspective. You can totally get by without bundlers in a project that doesn’t use any libraries. But the moment you add that first dependency, everything changes. You are suddenly faced with a huge usability cliff : which bundler to use, how to configure it, how to deploy with it, a mountain of decisions standing between you and your goal of using that one dependency. That one drag and drop library. For newcomers, this often comes very early in their introduction to the web platform, and it can be downright overwhelming. It is technically possible to use dependencies without bundlers, today. There are a few different approaches, and — I will not sugarcoat it — they all suck . There are three questions here: There is currently no good answer to any of them, only fragile workarounds held together by duct tape. Using a dependency should not need any additional song and dance besides “install this package” + “now import it here”. That’s it. That’s the minimum necessary to declare intent . And that’s precisely how it works in NodeJS and other JS runtimes. Anything beyond that is reducing signal-to-noise ratio , especially if it needs to be done separately for every project or worse, for every dependency. You may need to have something to bite hard on while reading the next few sections. It’s going to be bad . Typically, package managers like take care of deduplicating compatible package versions and may use a directory like to install packages. In theory, one could deploy as part of their website and directly reference files in client-side JS. For example, to use Vue : It works out of the box, and is a very natural thing to try the first time you install a package and you notice . Great, right? No. Not great. First, deploying your entire directory is both wasteful , and a security risk . In fact, most serverless hosts (e.g. Netlify or Vercel ) automatically remove it from the publicly deployed files after the build is finished. Additionally, it violates encapsulation : paths within a package are generally seen as an implementation detail of the package itself, and packages expose specifier exports like or that they map to internal paths. If you decide to circumvent this and link to files directly, you now need to update your import paths whenever you update the package. It is also fragile, as not every module is installed directly in — though those explicitly marked as app dependencies are. Another common path is importing from CDNs like Unpkg and JSDelivr . For Vue, it would look like this: It’s quick and easy. Nothing to install or configure! Great, right? No. Not great. It is always a bad idea to introduce a dependency on a whole other domain you do not control , and an even worse one when linking to executable code. First, there is the obvious security risk. Unless you link to a specific version, down to the patch number and/or use SRI , the resource could turn malicious overnight under your nose if the package is compromised. And even if you link to a specific version, there is always the risk that the CDN itself could get compromised. Who remembers polyfill.io ? But even supply-chain attacks aside, any third-party domain is an unnecessary additional point of failure . I still remember scrambling to change JSDelivr URLs to Unpkg during an outage right before one of my talks, or having to hunt down all my repos that used RawGit URLs when it sunset, including many libraries. The DX is also suboptimal. You lose the immediacy and resilience of local, relative paths. Without additional tooling ( Requestly , file edits, etc.), you now need to wait for CDN roundtrips even during local development. Wanted to code on a flight? Good luck. Needed to show a live demo during a talk, over clogged conference wifi? Maybe sacrifice a goat to the gods first. And while they maintain encapsulation slightly better than raw file imports, as they let you reference a package by its name for its default export, additional specifiers (e.g. ) typically still require importing by file path. “But with public CDNs, I benefit from the resource having already been cached by another website the user visited!” Oh my sweet summer child. I hate to be the one to break it to you, but no, you don’t, and that has been the case since about 2020 . Double keyed caching obliterated this advantage . In case you were not aware, yes, your browser will redownload every single resource anew for every single website (origin) that requests it. Yes, even if it’s exactly the same. This changed to prevent cross-site leaks : malicious websites could exfiltrate information about your past network activity by measuring how long a resource took to download, and thus infer whether it was cached. Those who have looked into this problem claim that there is no other way to prevent these timing attacks other than to actually redownload the resource. No way for the browser to even fake a download by simply delaying the response. Even requiring resources to opt-in (e.g. via CORS) was ruled out, the concern being that websites could then use it as a third-party tracking mechanism. I personally have trouble accepting that such wasteful bandwidth usage was the best balance of tradeoffs for all Web users , including those in emerging economies and different locales [1] . It’s not that I don’t see the risks — it’s that I am acutely aware of the cost, a cost that is disproportionately borne by those not in the Wealthy Western Web . How likely is it that a Web user in Zimbabwe, where 1 GB of bandwidth costs 17% of the median monthly income , would choose to download React or nine weights of Roboto thousands of times to avoid seeing personalized ads? And how patronizing is it for people in California to be making this decision for them? A quick and dirty way to get local URLs for local development and CDN URLs for the remote site is to link to relative URLs, and add a URL rewrite to a CDN if that is not found. E.g. with Netlify rewrites this looks like this: Since is not deployed, this will always redirect on the remote URL, while still allowing for local URLs during development. Great, right? No. Not great. Like the mythical hydra, it solves one problem and creates two new ones. First, it still carries many of the same issues of the approaches it combines: Additionally, it introduces a new problem: the two files need to match, but the naïve approach above would always just link to the latest version. Sure, one could alleviate this by building the file with tooling, to link to specific versions, read from . But the point is not that it’s insurmountable, but that it should not be this hard . Another solution is a lightweight build script that copies either entire packages or specific exports into a directory that will actually get deployed. When dependencies are few, this can be as simple as an npm script: So now we have our own nice subset of and we don’t depend on any third-party domains. Great, right? No. Not great. Just like most other solution, this still breaks encapsulation, forcing us to maintain a separate, ad-hoc index of specifiers to file paths. Additionally, it has no awareness of the dependency graph. Dependencies of dependencies need to be copied separately. But wait a second. Did I say dependencies of dependencies? How would that even work? In addition to their individual flaws, all of the solutions above share a major flaw: they can only handle importing dependency-free packages . But what happens if the package you’re importing also uses dependencies? It gets unimaginably worse my friend, that’s what happens. There is no reasonable way for a library author to link to dependencies without excluding certain consumer workflows. There is no local URL a library author can use to reliably link to dependencies, and CDN URLs are highly problematic. Specifiers are the only way here. So the moment you include a dependency that uses dependencies, you’re forced into specifier-based dependency management workflows , whether these are bundlers, or import map flavored JSON vomit in every single HTML page (discussed later). As a fig leaf, libraries will often provide a “browser” bundle that consumers can import instead of their normal , which does not use specifiers. This combines all their dependencies into a single dependency-free file that you can import from a browser. This means they can use whatever dependencies they want, and you can still import that bundle using regular ESM imports in a browser, sans bundler. Great, right? No. Not great. It’s called a bundle for a reason. It bundles all their dependencies too, and now they cannot be shared with any other dependency in your tree, even if it’s exactly the same version of exactly the same package. You’re not avoiding bundling, you’re outsourcing it , and multiplying the size of your JS code in the process. And if the library author has not done that, you’re stuck with little to do, besides a CDN that rewrites specifiers on the fly like esm.sh , with all CDN downsides described above. As someone who regularly releases open source packages ( some with billions of npm installs ), I find this incredibly frustrating. I want to write packages that can be consumed by people using or not using bundlers, without penalizing either group , but the only way to do that today is to basically not use any dependencies. I cannot even modularize my own packages without running into this! This doesn’t scale. Browsers can import specifiers, as long as the mapping to a URL is explicitly provided through an import map . Import maps look like this: Did you notice something? Yes, this is an HTML block. No, I cannot link to an import map that lives in a separate file. Instead, I have to include the darn thing in. Every. Single. Page. The moment you decide to use JS dependencies, you now need an HTML templating tool as well. 🙃 “💡 Oh I know, I’ll generate this from my library via DOM methods! ” I hear you say. No, my sweet summer child. It needs to be present at parse time. So unless you’re willing to it (please don’t), the answer is a big flat NOPE. “💡 Ok, at least I’ll keep it short by routing everything through a CDN or the same local folder ” No, my sweet summer child. Go to sleep and dream of globs and URLPatterns . Then wake up and get to work, because you actually need to specify. Every. Single. Mapping. Yes, transitive dependencies too. You wanted to use dependencies? You will pay with your blood, sweat, and tears. Or, well, another build tool. So now I need a build tool to manage the import map , like JSPM . It also needs to talk to my HTML templating tool, which I now had to add so it can spit out these import maps on. Every. Single. HTML. Page. There are three invariants that import maps violate: Plus, you still have all of the issues discussed above, because you still need URLs to link to. By trying to solve your problem with import maps, you now got multiple problems. To sum up, in their current form, import maps don’t eliminate bundlers — they recreate them in JSON form, while adding an HTML dependency and worse latency. Given the current state of the ecosystem, not using bundlers in any nontrivial application does seem like an exercise in masochism. Indeed, per State of JS 2024 , bundlers were extremely popular, with Webpack having been used by 9 in 10 developers and having close to 100% awareness! But sorting by sentiment paints a different picture, with satisfaction, interest, and positivity dropping year after year. Even those who never question the status quo can feel it in their gut that this is not okay. This is not a reasonable way to manage dependencies. This is not a healthy ecosystem. Out of curiosity, I also ran two polls on my own social media. Obviously, this suffers from selection bias , due to the snowball sampling nature of social media, but I was still surprised to see such a high percentage of bundle-less JS workflows: I’m very curious how these folks manage the problems discussed here. Oftentimes when discussing these issues, I get the question “but other languages are completely compiled, why is it a problem here?”. Yes, but their compiler is official and always there. You literally can’t use the language without it. The problem is not compilation, it’s fragmentation. It’s the experience of linking to a package via a browser import only to see errors about specifiers. It’s adding mountains of config and complexity to use a utility function. It’s having no clear path to write a package that uses another package, even if both are yours. Abstraction itself is not something to outsource to third-party tools. This is the programming equivalent of privatizing fundamental infrastructure — roads, law enforcement, healthcare — systems that work precisely because everyone can rely on them being there. Like boiling frogs , JS developers have resigned themselves to immense levels of complexity and gruntwork as simply how things are . The rise of AI introduced swaths of less technical folks to web development and their overwhelm and confusion is forcing us to take a long hard look at the current shape of the ecosystem — and it’s not pretty. Few things must always be part of a language’s standard library, but dependency management is absolutely one of them. Any cognitive overhead should be going into deciding which library to use, not whether to include it and how . This is also actively harming web platform architecture . Because bundlers are so ubiquitous, we have ended up designing the platform around them, when it should be the opposite. For example, because is unreliable when bundlers are used, components have no robust way to link to other resources (styles, images, icons, etc.) relative to themselves, unless these resources can be part of the module tree. So now we are adding features to the web platform that break any reasonable assumption about what HTML, CSS, and JS are, like JS imports for CSS and HTML, which could have been a simple if web platform features could be relied on. And because using dependencies is nontrivial, we are adding features to the standard library that could have been userland or even browser-provided dependencies. To reiterate, the problem isn’t that bundlers exist — it’s that they are the only viable way to get first-class dependency management on the web. JS developers deserve better. The web platform deserves better. As a web standards person, my first thought when spotting such a lacking is “how can the web platform improve?”. And after four years in the TAG , I cannot shake the holistic architectural perspective of “which part of the Web stack is best suited for this?” Before we can fix this, we need to understand why it is the way it is. What is the fundamental reason the JS ecosystem overwhelmingly prefers specifiers over URLs? On the surface, people often quote syntax, but that seems to be a red herring. There is little DX advantage of (a specifier) over (a URL), or even (which can be configured to have a JS MIME type). Another oft-cited reason is immutability: Remote URLs can change, whereas specifiers cannot. This also appears to be a red herring: local URLs can be just as immutable as specifiers. Digging deeper, it seems that the more fundamental reason has to do with purview . A URL is largely the same everywhere, whereas can resolve to different things depending on context. A specifier is app-controlled whereas a URL is not. There needs to be a standard location for a dependency to be located and referenced from, and that needs to be app-controlled. Additionally, specifiers are universal . Once a package is installed, it can be imported from anywhere, without having to work out paths. The closest HTTP URLs can get to this is root-relative URLs, and that’s still not quite the same. Specifiers are clearly the path of least resistance here, so the low hanging fruit would be to make it easier to map specifiers to URLs, starting by improving import maps. An area with huge room for improvement here is import maps . Both making it easier to generate and include import maps, and making the import maps themselves smaller, leaner, and easier to maintain. The biggest need here is external import maps , even if it’s only via . This would eliminate the dependency on HTML templating and opens the way for generating them with a simple build tool. This was actually part of the original import map work , and was removed from the spec due to lack of implementer interest, despite overwhelming demand. In 2022, external import maps were prototyped in WebKit (Safari), which prompted a new WHATWG issue . Unfortunately, it appears that progress has since stalled once more. External import maps do alleviate some of the core pain points, but are still globally managed in HTML, which hinders composability and requires heavier tooling. What if import maps could be imported into JS code? If JS could import import maps, (e.g. via ), this would eliminate the dependency on HTML altogether, allowing for scripts to localize their own import info, and for the graph to be progressively composed instead of globally managed. Going further, import maps via an HTTP header (e.g. ) would even allow webhosts to generate them for you and send them down the wire completely transparently. This could be the final missing piece for making dependencies truly first-class. Imagine a future where you just install packages and use specifiers without setting anything up, without compiling any files into other files, with the server transparently handling the mapping ! However, import maps need URLs to map specifiers to, so we also need some way to deploy the relevant subset of to public-facing URLs, as deploying the entire directory is not a viable option. One solution might be a way to explicitly mark dependencies as client side , possibly even specific exports. This would decouple detection from processing app files: in complex apps it can be managed via tooling, and in simple apps it could even be authored manually, since it would only include top-level dependencies. Even if we had better ways to mark which dependencies are client-side and map specifiers to URLs, these are still pieces of the puzzle, not the entire puzzle. Without a way to figure out what depends on what, transitive dependencies will still need to be managed globally at the top level, defeating any hope of a tooling-light workflow. The current system relies on reading and parsing thousands of files to build the dependency graph. This is reasonable for a JS runtime where the cost of file reads is negligible, but not for a browser where HTTP roundtrips are costly. And even if it were, this does not account for any tree-shaking. Think of how this works when using URLs: modules simply link to other URLs and the graph is progressively composed through these requests. What if specifiers could work the same way? What if we could look up and route specifiers when they are actually imported? Here’s a radical idea: What if specifiers were just another type of URL , and specifier resolution could be handled by the server in the same way a URL is resolved when it is requested? They could use a protocol, that can be omitted in certain contexts, such as ESM imports. How would these URLs be different than regular local URLs? Architecturally, this has several advantages: Obviously, this is just a loose strawman at this point, and would need a lot of work to turn into an actual proposal (which I’d be happy to help out with, with funding ), but I suspect we need some way to bridge the gap between these two fundamentally different ways to import modules. Too radical? Quite likely. But abstraction is foundational, and you often need radical solutions to fix foundational problems. Even if this is not the right path, I doubt incremental improvements can get us out of this mess for good. But in the end, this is about the problem . I’m much more confident that the problem needs solving, than I am of any particular solution. Hopefully, after reading this, so are you. So this is a call to action for the community. To browser vendors, to standards groups, to individual developers. Let’s fix this! 💪🏼 Thanks to Jordan Harband , Wes Todd , and Anne van Kesteren for reviewing earlier versions of this draft. In fact, when I was in the TAG, Sangwhan Moon and I drafted a Finding on the topic, but the TAG never reached consensus on it. ↩︎ Use specifiers or URLs? How to resolve specifiers to URLs? Which URL do my dependencies live at? Linking to CDNs is inherently insecure It breaks encapsulation of the dependencies Locality : Dependency declarations live in HTML, not JS. Libraries cannot declare their own dependencies. Composability : Import maps do not compose across dependencies and require global coordination Scalability : Mapping every transitive dependency is not viable without tooling Twitter/X poll : 17.6% of respondents Mastodon poll : 40% (!) of respondents Their protocol would be implied in certain contexts — that would be how we can import bare specifiers in ESM Their resolution would be customizable (e.g. through import maps, or even regular URL rewrites) Despite looking like absolute URLs, their resolution would depend on the request’s header (thus allowing different modules to use different versions of the same dependency). A request to a URL without an header would fail. HTTP caching would work differently; basically in a way that emulates the current behavior of the JS module cache. It bridges the gap between specifiers and URLs . Rather than having two entirely separate primitives for linking to a resource, it makes specifiers a high-level primitive and URLs the low-level primitive that explains it. It allows retrofitting specifiers into parts of the platform that were not designed for them, such as CSS . This is not theoretical: I was at a session at TPAC where bringing specifiers to CSS was discussed. With this, every part of the platform that takes URLs can now utilize specifiers, it would just need to specify the protocol explicitly. In fact, when I was in the TAG, Sangwhan Moon and I drafted a Finding on the topic, but the TAG never reached consensus on it. ↩︎
Here's a brief survey of the tools I'm currently using I use a 14-inch MacBook Pro M1 Max that I bought in 2021. It is, on balance, the best computer I have ever owned. The keyboard is usable (not the best macbook keyboard ever, but... fine), the screen is lovely, it sleeps when it's supposed to sleep and the battery still lasts a long time. The right shift key is broken, but using the left one hasn't proved to be much of a challenge. I usually replace my computers every 5 years, but I don't see any reason why I'd need a new one next year. The most important piece of software on my computer is neovim . I've been using vim-ish software since 2003 or so, and on balance I think the neovim team has done a great job shepherding the software into the modern age. I get a bit irritated that I need to edit my configuration more often than I used to with Vim, but coding tools are changing so fast right now that it doesn't seem possible to go back to the world where I edited my config file once every other year. My vim config is available here . I won't list all the plugins; there aren't that many , and most of them are trivial, rarely-used or both. The ones I need are: Now that vim has native LSP support, I could honestly probably get away with just those plugins. Here's a screenshot of what nvim looks like in a terminal window for me, with a telescope grep open: I use kitty and my config is here . I'd probably switch to ghostty if it supported opening hyperlinks in a terminal application , but it doesn't. I use this feature constantly so I want to explain a bit about why I find it so valuable. A common workflow looks like this: Here's a video demonstrating how it works: The kitty actions are configured in this file - the idea is that you connect a mime type or file extension to an action; in this case it's for a filename without a line number, and if it has a line number. I switched from Firefox to Orion recently, mostly because I get the urge to switch browsers every six months or so when each of their annoyances accumulate. Orion definitely has bugs, and is slow in places, and Safari's inspector isn't nearly as nice as Firefox or Chrome. I wouldn't recommend other people switch, even though I'm currently enjoying it. That's it, I don't use any more. Browser plugins have a terrible security story and should be avoided as much as possible. I use Obsidian , which I publish to the web with the code here (see generating HTML )) It's not clear to me why I like using obsidian rather than just editing markdown files in vim, but I'm very happy with it. I use Apple's Mail.app. It's... fine enough I guess. I also occasionally use the fastmail web app, and it's alright too. I am very happy with Fastmail as an email host, and glad I switched from gmail a decade or so ago. I use Slack for work chat and several friend group chats. I hate it even though it's the best chat app I've ever used. I desperately want a chat app that doesn't suck and isn't beholden to Salesforce, but I hate Discord and IRC. I've made some minor attempts at replacing it with no success. It's the piece of software I use day to day that I would most love to replace. I also use Messages.app for SMS and texts I switched in October from Spotify to Apple Music. I dislike Apple Music, but I also disliked Spotify ever since I switched from Rdio when it died. I'm still on the lookout for a good music listening app. Maybe I'll try Qobuz or something? I don't know. I've also used yt-dlp to download a whole bunch of concerts and DJ sets from youtube (see youtube concerts for a list of some of them) and I often listen to those. I have an old iPhone mounted to my desk, and use Reincubate Camo to connect it to video apps. I occasionally use OBS to record a video or add a goofy overlay to video calls, but not that often. I use Adobe Lightroom to import photos from my Fuji X-T30 and Apple Photos to manage photos. With the demise of flickr, I really have no place to post my photos and I've considered adding something to my website but haven't gotten it done. I use vlc and IINA for playing videos, and ffmpeg for chopping them up from the command line Software editor neovim plugins terminal software Browser browser plugins Note Taking telescope.nvim I have "open by filename search" bound to and "open by grep search" bound to , and those are probably the two most common tools I use in vim. Telescope looks great and works fast (I use telescope-fzf-native for grep) codecompanion I added this in January last year, and it's become the default way I interact with LLMs. It has generally very nice features for working with buffers in vim and handles communications with LLMs cleanly and simply. You don't need Cursor et al to work with LLMs in your editor! I do use claude code for agentic missions, as I have not had much success with agentic mode in codecompanion - I use it more for smaller tasks while I'm coding, and it's low-friction enough to make that pretty painless sonokai colorscheme I use a customized version , and it makes me happy. I to a project directory I either remember a file name or a string I can search for to find where I want to work In the former case, I do In the latter, I do Then I click on the filename or line number to open that file or jump to that line number in a file I love mise for managing versions of programming environments. I use it for node, terraform, go, python, etc etc I have files in most of my projects which set important environment variables, so it has replaced direnv for me as well fd for finding files, a better find ripgrep for grepping atuin for recording my command line history GNU Make and occasionally Just for running tasks is broken and annoying in old, predictable ways; but I know how it works and it's available everywhere here's an example makefile I made for a modern js project is modern in important ways but also doesn't support output file targets, which is a feature I commonly use in ; see the above file for an example gh for interacting with github. I particularly use my alias for quite a lot to open pull requests jq for manipulating json llm for interacting with LLMs from the command line; see An AI tool I find useful for an example Bitwarden - I dunno, it's fine, it mostly doesn't annoy me uBlock origin - I'm very happy with it as an ad blocker
Since we’re at the start of a new year, I will stop fine-tuning everything on this blog and let it live as the receptacle it’s supposed to be. With my mind cleared of HTML and CSS concerns, I now have energy to waste on new optimisations of my digital environment, and this time with an old favourite of mine: content blockers. * 1 In 2022, I experimented with blocking JavaScript on a per-site basis , which, at the time, allowed me to feel better about my behaviour on the web. You see, I thought that I was not actively refusing adverts. I was just disabling a specific technology on my web browser; not my fault if most ads are enabled via JS after all. True, ads couldn’t reach my house, but not because I actively refused their delivery; simply because the trucks used for their delivery weren’t allowed to drive on my pedestrian-only street. Ethically, I preferred this approach to the one blocking all ads blindly on every site, even if the consequences, from the publishers’ perspective, were the same. I know it was very hypocritical of me, and I know I was still technically blocking the ads. Nevertheless, I felt less guilty blocking the technology used for ads, and not the ads directly. This setup was fine, until it wasn’t. My web experience was not great. Blocking JavaScript by default breaks too many non-media sites, and letting it on made me realise how awful browsing the web without a content blocker can be. The only way for this system to work was to have patience and discipline on the per-site settings. Eventually, I gave up and reinstalled the excellent Wipr Safari extension on all my devices a few weeks later. Last year, on top of Wipr , I also tried services like NextDNS and Mullvad DNS . With these, the browser ad blocker becomes almost superfluous, as all it has to do is remove empty boxes that were supposed to be ads before being blocked by the DNS. It was an efficient setup, but I was still blocking ads, which kept on bothering me. While I happily support financially a few publications, I can’t do the same for all the sites I visit. For the ones I am not paying, seeing ads seems like a fair deal; blocking ads was making me feel increasingly guilty. * 2 Like I wrote in the other post on the topic : Somehow, I always feel a little bit of shame and guilt when talking about content blockers, especially ad blockers. Obviously ads are too often the only way many publishers manage to make decent money on the internet: every newspaper can’t be financially successful with subscriptions, and every media company can’t survive only on contributions and grants. That’s why recently, I stopped using Mullvad as my DNS resolver, and switched to Quad9 , which focuses on privacy-protection and not ad-blocking. I also uninstalled Wipr. Today, I rely solely on StopTheScript . What’s new this time around is that I will try to be more disciplined than I was three years ago, and do the work to make this system last. What I do is set the default StopTheScript setting on “Ask”. When a site aggressively welcomes me with three or four banners masking the article I came to read, I click on the StopTheScript icon and allow it to block JavaScript on the website, and refresh the page. Two clicks, one keyboard shortcut. In most cases, these steps are easier and faster than what is the usual series of events. You know, the one where you need to reload the page with ad blockers disabled, just so you can close the modal window that was blocking scrolling on the page, and then reload the page once again, this time with ad blockers enabled. With JavaScript turned off, visiting most websites is a breeze: my computer feels like it uses an M4 chip and not an Intel Core i5, the page is clean, the article is there, it works. There are a few media sites that refuse to display anything with JS turned off, but I’d say that 95% of the time it’s fine, and I can live my life without a proper ad blocker. * 3 For websites where ads are tolerable, I don’t bother blocking JavaScript, I let it pass. In my mind, this is how my first interaction with a website goes if it were a department store: [opens page at URL] Website: “ Hi dear visitor, I see you’re looking at this product, but may I interest you in a free newsletter? Or would you like to share your Google account with us so next time you come back we’ll know? Also, could you sign this agreement real quick? Oh, and by the way, have you seen that we have a special offer currently? Would you like a cookie? ” Me: “ Hello, yes, oh wow, hum… wait a second… ” [blocks JavaScript] Me: “ Sorry, I don’t speak your language and don’t understand anything you say .” [Salesperson goes away instantly] Me: “ Ah, this is nice and quiet. ” Maybe I’m wrong, but to me, this is a more “polite” default behaviour than using an ad blocker from the get-go, which, in this analogy, would be something like this: [opens page at URL] Ad blocker: “ Alright, well done team, great job. We arrested all sales people, handcuffed them, and brought them all to in the basement. All clear. The boss can come in. ” Me: “ Ah, this is nice and quiet. ” If you have a better analogy, I’m all ears: I really struggled with this one. I’m not sure how long this JS blocking setup will last this time. I’m not sure if it feels that much better to block JS permanently on some websites rather than blocking ads. All I know is that most websites are much quicker to load without JavaScript, much easier to handle by my machine, and just for those reasons, StopTheScript may be the best content blocker for Safari. I guess this is not surprising that all the cool new web browsers include a JavaScript toggle natively. Why are they called content blockers and not ad blockers? Pretty sure it’s some sort of diplomatic lingo used to avoid hurting the feelings of ad companies. I don’t like the word content , but calling ads and trackers content is just weird. ^ I know I could use an ad blocker and disable it on some websites, or only activate it on the most annoying sites, but ad blockers tend to disappear in the background, don’t they? ^ I mention media sites because obviously ecommerce sites, video sites, and interactive sites require JavaScript. Interestingly, Mastodon doesn’t need it to display posts, whereas Bluesky does. ^ Why are they called content blockers and not ad blockers? Pretty sure it’s some sort of diplomatic lingo used to avoid hurting the feelings of ad companies. I don’t like the word content , but calling ads and trackers content is just weird. ^ I know I could use an ad blocker and disable it on some websites, or only activate it on the most annoying sites, but ad blockers tend to disappear in the background, don’t they? ^ I mention media sites because obviously ecommerce sites, video sites, and interactive sites require JavaScript. Interestingly, Mastodon doesn’t need it to display posts, whereas Bluesky does. ^
This week on the People and Blogs series we have an interview with V.H. Belvadi, whose blog can be found at vhbelvadi.com . Tired of RSS? Read this in your browser or sign up for the newsletter . The People and Blogs series is supported by Flamed and the other 130 members of my "One a Month" club. If you enjoy P&B, consider becoming one for as little as 1 dollar a month. I’m currently a Trinity–Cambridge researcher at the University of Cambridge, pursuing my PhD on the development of climate models. I’m also a researcher on the Cambridge ThinkLab group examining the credibility of AI models. My background is in condensed matter physics, which previously led to my research in astrophysics studying a type of eruptive variable star, and that in turn helped broaden my interests in the fascinating field of the history of science, about which I remain very passionate today. I’ve enjoyed writing for as long as I can remember and I write on my website about a wide range of topics, but mostly centred around science, technology, history and society. I also run an infrequently despatched newsletter that discusses similar themes. In my spare time I make photographs and engage with my local photography club, read a lot, punt on the Cam, ride my Brompton, take long walks or participate in the Cambridge Union, which happens to be the world’s oldest debating society. To be honest, it’s quite unremarkable. I first came across the idea of a weblog through an explainer in a physical magazine. My earliest website was a bunch of hard-coded html pages uploaded to my ISP’s free subdomain. I eventually moved to LiveJournal and then to Vox, which had just been launched (and about which I still have fond memories). In 2008 I moved to Wordpress, because that’s where seemingly everyone was, and I stayed there for about eight years. Between 2016 and 2018, in search of better alternatives because I had started to feel Wordpress was bloated, I tried Kirby and then Hugo and finally Statamic. Over the years my blog has had many names, all of which are best forgotten. Today it’s eponymous. My perennial motivation has been the joy of seeing my thoughts printed on screen. The general structure I have on my website now, besides my ‘notes’, has been the structure I’ve had since the early 2000s. (My notes were on Tumblr.) Besides all that, I like that in my website I have a safe space in which to engage with a multitude of ideas and sharpen my thinking through my writing. I’m starting to get the feeling all my answers are going to be unremarkable. I don’t really have a creative process mostly because I don’t force myself to write at specific intervals for my website and because I find I do not work well with ‘knowledge gathering’ disconnected from a purpose for that knowledge. What this means is that ideas incubate in my head as I read things, and over time one, or a set of ideas, will reach critical density, prompting me to write something. Consequently, by this point I usually know what I want to say, so I just sit down and write it. I already do a lot of writing as an academic and deal with plenty of deadlines, so the last thing I want is to replicate that environment on my personal website. As a result some things I do tend to be polar opposites: I keep no schedule, I give myself no deadlines, and I publish my first drafts – warts and all – with little proofreading, or throw away entire essays at times. This is not to say I never refine my writing, but I generally try not let a sense of perfection get in my way. I also, therefore, permit myself plenty of addenda and errata. I write in BBEdit and publish from BBEdit using SFTP. I have a bunch of scripts, clippings etc. on that wonderful programme and am yet to find an equal. If I am on my mobile I use the dashboard built into my site, but usually only for fixing typos and not for typing entire essays. I may type entire notes this way, however, because notes on my website are usually quite brief. And if I ever want to make note of something for later or return to a webpage, I either save it to my Safari reading list or make a note on Apple Notes. However, I rarely make separate, atomic notes anymore (I did try to at one point), choosing instead to write a few lines summarising a source and saving the source itself. In case of my RSS subscriptions (I use NetNewsWire) I star posts for later reference but prefer to read on the actual website, as the writer intended. I can write anywhere but there certainly are some things that make writing a more pleasant experience. Good music has no equal and I prefer classical music (which varies widely from Mozart to George Winston) or ambient works like those of Roger Eno and Enya; if push comes to shove, anything without words will do. I prefer quiet places, places from where I can see the natural world around me and a warm cup of coffee, none of which are absolute necessities. The environment on my computer is probably a bit more controlled: I like to write on BBEdit, as I said before, and in full screen with, perhaps, Safari on a neighbouring workspace. My website is hosted on a VPS with Hetzner, which I also use to self-host a few other things like a budgeting software , a reference manager , Plausible and Sendy. It runs on Statamic and is version-controlled with Git. My domain is registered with Cloudflare. In the past I used mostly shared hosting. I also maintain an updated list of stuff I use daily on my website for some inexplicable reason. It costs me about £5 a month to run my website, including daily automated backups. I neither generate revenue through it now nor plan to in the future. I do not have thoughts on people monetising their personal blogs. However, if their attempts at doing so involve ruining their writing, presenting misleading content or plastering ads all over their page, I might not be inclined to return to their site or recommend it to others. I know how wonderful it felt when people showed support for my website through small donations so I like to give similarly when I can afford to do so. Amongst those who have not already been interviewed on People & Blogs, here are four people who are far more interesting than I am: Juha-Matti Santala , Pete Moore , Melanie Richards and Anthony Nelzin-Santos . (This in no way means there isn’t a fifth person more interesting than me.) I feel a strong urge to apologise for my responses but I’ll instead take a moment to nudge people to subscribe to my newsletter if that’s something they’d like, or visit my website and start a conversation with me about something either they found interesting or with which they disagree. If you have 30 min to spare, head over to ncase.me/trust/ for an interactive website designed to illustrate the evolution of trust according to game theory. But if you have less than 30 min, here’s a ‘tediously accurate scale model’ of the solar system that is the internet edition of Carl Sagan’s pale blue dot. Besides all this, I’d encourage people to help build a better, more inclusive and kinder world for everyone by engaging meaningfully both online and offline (although not at the cost of your own mental health). Slow down, read more books and please don’t lose your attention span. Now that you're done reading the interview, go check the blog and subscribe to the RSS feed . If you're looking for more content, go read one of the previous 122 interviews . Make sure to also say thank you to Paolo Ruggeri and the other 130 supporters for making this series possible. I would not waste my time on targeting niches and optimising for search engines, given my intentions with my website. I thought they were intended to grow traffic – as they are – but I came to realise that was not the sort of traffic I valued. I would prioritise platform agnosticism so I can move to better platforms in the future, should I choose to, without losing any of my work. I have lost much of my writings when jumping platforms in the past because I had to move my content over manually and chose to move select writings to save time. (Or was it because I was a bit lazy?) I would probably not delete my old work as I outgrow them, choosing instead to keep them private. I have, peculiarly and thoughtlessly, deleted my work at regular intervals multiple times in the past.
One of the small joys of running a static blog is scheduling posts in advance. Write a few pieces when inspiration strikes, set future dates, and let them publish themselves while you’re busy with other things. There’s just one problem: static sites don’t work that way out of the box. With a dynamic CMS like WordPress, scheduling is built in. The server checks the current time, compares it to your post’s publish date, and serves it up when the moment arrives. Simple. Static site generators like Hugo work differently. When you build the site, Hugo looks at all your content, checks which posts have dates in the past, and generates HTML for those. Future-dated posts get skipped entirely. They don’t exist in the built output. This means if you write a post today with tomorrow’s date, it won’t appear until you rebuild the site tomorrow. And if you’re using Netlify’s automatic deploys from Git, that rebuild only happens when you push a commit. No commit, no deploy, no post. I could set a reminder to push an empty commit every morning. But that defeats the purpose of scheduling posts in the first place. The fix is straightforward: trigger a Netlify build automatically every day, whether or not there’s new code to deploy. Netlify provides build hooks for exactly this purpose. A build hook is a unique URL that triggers a new deploy when you send a POST request to it. All you need is something to call that URL on a schedule. GitHub Actions handles the scheduling side. A simple workflow with a cron trigger runs every day at midnight UK time and pings the build hook. Netlify does the rest. First, create a build hook in Netlify: Next, add that URL as a secret in your GitHub repository: Finally, create a workflow file at : The dual cron schedule handles UK daylight saving time. During winter (GMT), the first schedule fires at midnight. During summer (BST), the second one does. There’s a brief overlap during the DST transitions where both might run, but an extra deploy is harmless. The trigger is optional but handy. It adds a “Run workflow” button in the GitHub Actions UI, letting you trigger a deploy manually without pushing a commit. Now every morning at 00:01, GitHub Actions wakes up, pokes the Netlify build hook, and a fresh deploy rolls out. Any posts with today’s date appear automatically. No manual intervention required. It’s a small piece of automation, but it removes just enough friction to make scheduling posts actually practical. Write when you want, publish when you planned. Go to your site’s dashboard Navigate to Site settings → Build & deploy → Build hooks Click Add build hook , give it a name, and select your production branch Copy the generated URL Go to Settings → Secrets and variables → Actions Create a new repository secret called Paste the build hook URL as the value
I am a huge fan of gistpreview.github.io , the site by Leon Huang that lets you append to see a browser-rendered version of an HTML page that you have saved to a Gist. The last commit was ten years ago and I needed a couple of small changes so I've forked it and deployed an updated version at gisthost.github.io . The genius thing about is that it's a core piece of GitHub infrastructure, hosted and cost-covered entirely by GitHub, that wasn't built with any involvement from GitHub at all. To understand how it works we need to first talk about Gists. Any file hosted in a GitHub Gist can be accessed via a direct URL that looks like this: That URL is served with a few key HTTP headers: These ensure that every file is treated by browsers as plan text, so HTML file will not be rendered even by older browsers that attempt to guess the content type based on the content. These confirm that the file is sever via GitHub's caching CDN, which means I don't feel guilty about linking to them for potentially high traffic scenarios. This is my favorite HTTP header! It means I can hit these files with a call from any domain on the internet, which is fantastic for building HTML tools that do useful things with content hosted in a Gist. The one big catch is that Content-Type header. It means you can't use a Gist to serve HTML files that people can view. That's where comes in. The site belongs to the dedicated gistpreview GitHub organization, and is served out of the github.com/gistpreview/gistpreview.github.io repository by GitHub Pages. It's not much code. The key functionality is this snippet of JavaScript from main.js : This chain of promises fetches the Gist content from the GitHub API, finds the section of that JSON corresponding to the requested file name and then outputs it to the page like this: This is smart. Injecting the content using would fail to execute inline scripts. Using causes the browser to treat the HTML as if it was directly part of the parent page. That's pretty much the whole trick! Read the Gist ID from the query string, fetch the content via the JSON API and it into the page. Here's a demo: https://gistpreview.github.io/?d168778e8e62f65886000f3f314d63e3 I forked to add two new features: I also removed some dependencies (jQuery and Bootstrap and an old polyfill) and inlined the JavaScript into a single index.html file . The Substack issue was small but frustrating. If you email out a link to a page via Substack it modifies the URL to look like this: https://gistpreview.github.io/?f40971b693024fbe984a68b73cc283d2=&utm_source=substack&utm_medium=email This breaks because it treats as the Gist ID. The fix is to read everything up to that equals sign. I submitted a PR for that back in November. The second issue around truncated files was reported against my claude-code-transcripts project a few days ago. That project provides a CLI tool for exporting HTML rendered versions of Claude Code sessions. It includes a option which uses the CLI tool to publish the resulting HTML to a Gist and returns a gistpreview URL that the user can share. These exports can get pretty big, and some of the resulting HTML was past the size limit of what comes back from the Gist API. As of claude-code-transcripts 0.5 the option now publishes to gisthost.github.io instead, fixing both bugs. Here's the Claude Code transcript that refactored Gist Host to remove those dependencies, which I published to Gist Host using the following command: You are only seeing the long-form articles from my blog. Subscribe to /atom/everything/ to get all of my posts, or take a look at my other subscription options . A workaround for Substack mangling the URLs The ability to serve larger files that get truncated in the JSON API
I almost missed the deadline with this one, didn’t I? At least it gives me a chance to wish every one of you a happy New Year’s Eve, and new year. In 2026, I’ll write less about CSS, fonts, HTML, and text editors, and more about… well, at least I’ll try. Thank you for reading. The Future of Veritasium ▪︎ Precious testimonial on what it really means to depend on the algorithm for revenue, and on how many people actually work in the background of a successful and quality YouTube channel like Veritasium. Mouseless ▪︎ If this app is definitely not for me — I tried — it may be appealing to some of you; I found the concept very intriguing; I can see how effective it could be in some apps that require a lot of hovering and clicking. (via Pierre Carrier ) Everpen ▪︎ I’ve been intrigued by this for a while now, and 2026 may be the year when I try this. I currently love using my fountain pen at my desk, but I prefer to travel with a pencil in my bag, and this may be the perfect companion for me. Predictions for Journalism 2026, Nieman Journalism Lab ▪︎ Every year, I look forward to reading these predictions; I just wish scrolling the page didn’t make my laptop activate its “vacuum cleaner noise” mode (I had to browse the “cards” via my RSS reader: I know, it’s time for me to upgrade ). Nick Heer, People and Blogs ▪︎ “ there is no better spellchecker than the ‘publish’ button. ” If you don’t follow the People and Blogs interview series , you are missing out. Grid Paper ▪︎ An excellent bookmark to add to your collection of utilities, especially interesting if, like me, you waste many high-quality notebook pages trying to do isometric drawings, and failing miserably. The Land of Giants Transmission Towers ▪︎ I love this and I keep thinking about it since I learned about it: Why isn’t it already a thing? Truly mesmerising, and I found that the illustrations used on their website are very tasteful too. (via Kottke ) Norm Architects ▪︎ As a fanboy of Norm Architects, I don’t know whether I like more their work or the photographs of their work. For years now, I’ve had one of an older batch of press pictures as a desktop wallpaper (you’ll know it when you see it) and another as my phone wallpaper. The colours, the lights, the shades, the textures: superb. How To Spot Arial ▪︎ Sorry, I’m writing about typefaces once again , but I think this is an important skill to have. (via Gruber ) Rubio Orders State Department Braille Signage Switch To ‘Times New Roman’ ▪︎ I promise, this is the last time I’ll be sharing something about typography and fonts until the end of the year. More “Blend of links” posts
Earlier today I noticed the syntax highlighting on this website was broken. But not fully: on reload I’d see a flash of highlighted text, that then turned monochrome. The raw HTML from showed rouge tags, but the web inspector showed raw text inside the elements. This didn’t happen in Chromium. My first thought was: there’s malformed HTML, and Firefox is recovering in a way that loses the DOM inside tags. Then I noticed it doesn’t happen in incognito. Turning my extensions off one by one, I found that 1Password is responsible. Others ( 1 , 2 ) have reported this also. If you extract the latest XPI , unzip it, and dig around, you’ll find they’re using Prism.js , a JavaScript syntax highlighter. I don’t know why a password manager needs a syntax highlighter. I imagine it has to do with the app feature where, if you have an SSH key, you can open a modal that tells you how to configure Git commit signing using. Maybe they want to highlight the SSH configuration code block (which is unnecessary anyways, since you could write that HTML by hand). But I can’t know for sure. Why write about this? Because 1Password is a security critical product, and they are apparently pulling random JavaScript dependencies and unwittingly running them in the tab context , where the code has access to everything. This is no good. I don’t need to explain how bad a supply-chain attack on the 1Password browser extension would be. I like 1Password and I was sad when Apple Sherlocked them with the Passwords app, but this is a bad sign about their security practices.
I've released claude-code-transcripts , a new Python CLI tool for converting Claude Code transcripts to detailed HTML pages that provide a better interface for understanding what Claude Code has done than even Claude Code itself. The resulting transcripts are also designed to be shared, using any static HTML hosting or even via GitHub Gists. Here's the quick start, with no installation required if you already have uv : (Or you could or first, if you like.) This will bring up a list of your local Claude Code sessions. Hit up and down to select one, then hit . The tool will create a new folder with an file showing a summary of the transcript and one or more files with the full details of everything that happened. Visit this example page to see a lengthy (12 page) transcript produced using this tool. If you have the gh CLI tool installed and authenticated you can add the option - the transcript you select will then be automatically shared to a new Gist and a link provided to to view it. can also fetch sessions from Claude Code for web. I reverse-engineered the private API for this (so I hope it continues to work), but right now you can run: Then select a Claude Code for web session and have that converted to HTML and published as a Gist as well. The claude-code-transcripts README has full details of the other options provided by the tool. These days I'm writing significantly more code via Claude Code than by typing text into a text editor myself. I'm actually getting more coding work done on my phone than on my laptop, thanks to the Claude Code interface in Anthropic's Claude iPhone app. Being able to have an idea on a walk and turn that into working, tested and documented code from a couple of prompts on my phone is a truly science fiction way of working. I'm enjoying it a lot. There's one problem: the actual work that I do is now increasingly represented by these Claude conversations. Those transcripts capture extremely important context about my projects: what I asked for, what Claude suggested, decisions I made, and Claude's own justification for the decisions it made while implementing a feature. I value these transcripts a lot! They help me figure out which prompting strategies work, and they provide an invaluable record of the decisions that went into building features. In the pre-LLM era I relied on issues and issue comments to record all of this extra project context, but now those conversations are happening in the Claude Code interface instead. I've made several past attempts at solving this problem. The first was pasting Claude Code terminal sessions into a shareable format - I built a custom tool for that (called terminal-to-html and I've used it a lot, but it misses a bunch of detail - including the default-invisible thinking traces that Claude Code generates while working on a task. I've also built claude-code-timeline and codex-timeline as HTML tool viewers for JSON transcripts from both Claude Code and Codex. Those work pretty well, but still are not quite as human-friendly as I'd like. An even bigger problem is Claude Code for web - Anthropic's asynchronous coding agent, which is the thing I've been using from my phone. Getting transcripts out of that is even harder! I've been synchronizing them down to my laptop just so I can copy and paste from the terminal but that's a pretty inelegant solution. You won't be surprised to hear that every inch of this new tool was built using Claude. You can browse the commit log to find links to the transcripts for each commit, many of them published using the tool itself. Here are some recent examples: I had Claude use the following dependencies: And for development dependencies: The one bit that wasn't done with Claude Code was reverse engineering Claude Code itself to figure out how to retrieve session JSON from Claude Code for web. I know Claude Code can reverse engineer itself, but it felt a bit more subversive to have OpenAI Codex CLI do it instead. Here's that transcript - I had Codex use to pretty-print the obfuscated Claude Code JavaScript, then asked it to dig out the API and authentication details. Codex came up with this beautiful command: The really neat trick there is the way it extracts Claude Code's OAuth token from the macOS Keychain using the command. I ended up using that trick in itself! You are only seeing the long-form articles from my blog. Subscribe to /atom/everything/ to get all of my posts, or take a look at my other subscription options . c80b1dee Rename tool from claude-code-publish to claude-code-transcripts - transcript ad3e9a05 Update README for latest changes - transcript e1013c54 Add autouse fixture to mock webbrowser.open in tests - transcript 77512e5d Add Jinja2 templates for HTML generation (#2) - transcript b3e038ad Add version flag to CLI (#1) - transcript click and click-default-group for building the CLI Jinja2 for HTML templating - a late refactoring, the initial system used Python string concatenation httpx for making HTTP requests markdown for converting Markdown to HTML questionary - new to me, suggested by Claude - to implement the interactive list selection UI pytest - always pytest-httpx to mock HTTP requests in tests syrupy for snapshot testing - with a tool like this that generates complex HTML snapshot testing is a great way to keep the tests robust and simple. Here's that collection of snapshots .
In this post, I return with a retrospective on my coding adventures, where I summarise my hobby projects and recreational programming activities from the current year. I did the last such retrospective in 2023 . So I think this is a good time to do another retrospective. At the outset, I should mention that I have done less hobby computing this year than in the past few, largely because I spent a substantial portion of my leisure time studying Galois theory and algebraic graph theory. In case you are wondering where I am learning these subjects from, the books are Galois Theory , 5th ed. by Ian Stewart and Algebraic Graph Theory by Godsil and Royle. Both are absolutely fascinating subjects and the two books I mentioned are quite good as well. I highly recommend them. Now back to the coding adventures. Here they go: MathB : The year began not with the release of a new project but with the opposite: discontinuing a project I had maintained for 13 years. MathB.in, a mathematics pastebin service, was discontinued early this year. This is a project I developed in 2012 for myself and my friends. Although a rather simple project, it was close to my heart, as I have many fond memories of exchanging mathematical puzzles and solutions with my friends using this service. Over time, the project grew quite popular on IRC networks, as well as in some schools and universities, where IRC users, learners, and students used the service to share problems and solutions with one another, much as my friends and I had done in its early days. I shut it down this year because I wanted to move on from the project. Before the shutdown, a kind member of the Archive Team worked with me to archive all posts from the now-defunct website. Although shutting down this service was a bittersweet event for me, I feel relieved that I no longer have to run a live service in my spare time. While this was a good hobby ten years ago, it no longer is. See my blog post MathB.in Is Shutting Down for more details on the reasons behind this decision. The source code of this project remains open source and available at github.com/susam/mathb . QuickQWERTY : This is a touch-typing tutor that runs in a web browser. I originally developed it in 2008 for myself and my friends. While I learned touch typing on an actual typewriter as a child, those lessons did not stick with me. Much later, while I was at university, I came across a Java applet-based touch-typing tutor that finally helped me learn touch typing properly. I disliked installing Java plugins in the web browser, which is why I later developed this project in plain HTML and JavaScript. This year, I carried out a major refactoring to collapse the entire project into a single standalone HTML file with no external dependencies. The source code has been greatly simplified as well. When I was younger and more naive, inspired by the complexity and multiple layers of abstraction I saw in popular open source and professional projects, I tended to introduce similar abstractions and complexity into my personal projects. Over time, however, I began to appreciate simplicity. The new code for this project is smaller and simpler, and I am quite happy with the end result. You can take a look at the code here: quickqwerty.html . If you want to use the typing tutor, go here: QuickQWERTY . Unfortunately, it does not support keyboard layouts other than QWERTY. When I originally developed this project, my view of the computing world was rather limited. I was not even aware that other keyboard layouts existed. You are, however, very welcome to fork the project and adapt the lessons for other layouts. CFRS[] : This project was my first contribution to the quirky world of esolangs. CFRS[] is an extremely minimal drawing language consisting of only six simple commands: , , , , , and . I developed it in 2023 and have since been maintaining it with occasional bug fixes. This year, I fixed an annoying bug that caused the drawing canvas to overflow on some mobile web browsers. A new demo also arrived from the community this year and has now been added to the community demo page. See Glimmering Galaxy for the new demo. If you want to play with CFRS[] now, visit CFRS[] . FXYT : This is another esolang project of mine. This too is a minimal drawing language, though not as minimal as CFRS[]. Instead, it is a stack-based, postfix canvas colouring language with only 36 simple commands. The canvas overflow bug described in the previous entry affected this project as well. That has now been fixed. Further, by popular demand, the maximum allowed code length has been increased from 256 bytes to 1024 bytes. This means there is now more room for writing more complex FXYT programs. Additionally, the maximum code length for distributable demo links has been increased from 64 bytes to 256 bytes. This allows several more impressive demos to have their own distributable links. Visit FXYT to try it out now. See also the Community Demos to view some fascinating artwork created by the community. Nerd Quiz : This is a new project I created a couple of months ago. It is a simple HTML tool that lets you test your nerdiness through short quizzes. Each question is drawn from my everyday moments of reading, writing, thinking, learning, and exploring. The project is meant to serve as a repository of interesting facts I come across in daily life, captured in the form of quiz questions. Go here to try it out: Nerd Quiz . I hope you will enjoy these little bits of knowledge as much as I enjoyed discovering them. Mark V. Shaney Junior : Finally, I have my own Markov gibberish generator. Always wanted to have one. The project is inspired by the legendary Usenet bot named Mark V. Shaney that used to post messages to various newsgroups in the 1980s. My Markov chain program is written in about 30 lines of Python. I ran it on my 24 years of blog posts consisting of over 200 posts and about 200,000 words and it generated some pretty interesting gibberish. See my blog post Fed 24 Years of My Posts to Markov Model to see the examples. Elliptical Python Programming : If the previous item was not silly enough, this one surely is. Earlier this year, I wrote a blog post describing the fine art of Python programming using copious amounts of ellipses. I will not discuss it further here to avoid spoilers. I'll just say that any day I'm able to do something pointless, whimsical and fun with computers is a good day for me. And it was a good day when I wrote this post. Please visit the link above to read the post. I hope you find it fun. Fizz Buzz with Cosines : Another silly post in which I explain how to compute the discrete Fourier transform of the Fizz Buzz sequence and derive a closed-form expression that can be used to print the sequence. Fizz Buzz in CSS : Yet another Fizz Buzz implementation, this time using just four lines of CSS. That wraps up my coding adventures for this year. There were fewer hobby projects than usual but I enjoyed spending more time learning new things and revisiting old ones. One long-running project came to an end, another was cleaned up and a few small new ideas appeared along the way. Looking forward to what the next year brings. Read on website | #programming | #technology | #retrospective MathB : The year began not with the release of a new project but with the opposite: discontinuing a project I had maintained for 13 years. MathB.in, a mathematics pastebin service, was discontinued early this year. This is a project I developed in 2012 for myself and my friends. Although a rather simple project, it was close to my heart, as I have many fond memories of exchanging mathematical puzzles and solutions with my friends using this service. Over time, the project grew quite popular on IRC networks, as well as in some schools and universities, where IRC users, learners, and students used the service to share problems and solutions with one another, much as my friends and I had done in its early days. I shut it down this year because I wanted to move on from the project. Before the shutdown, a kind member of the Archive Team worked with me to archive all posts from the now-defunct website. Although shutting down this service was a bittersweet event for me, I feel relieved that I no longer have to run a live service in my spare time. While this was a good hobby ten years ago, it no longer is. See my blog post MathB.in Is Shutting Down for more details on the reasons behind this decision. The source code of this project remains open source and available at github.com/susam/mathb . QuickQWERTY : This is a touch-typing tutor that runs in a web browser. I originally developed it in 2008 for myself and my friends. While I learned touch typing on an actual typewriter as a child, those lessons did not stick with me. Much later, while I was at university, I came across a Java applet-based touch-typing tutor that finally helped me learn touch typing properly. I disliked installing Java plugins in the web browser, which is why I later developed this project in plain HTML and JavaScript. This year, I carried out a major refactoring to collapse the entire project into a single standalone HTML file with no external dependencies. The source code has been greatly simplified as well. When I was younger and more naive, inspired by the complexity and multiple layers of abstraction I saw in popular open source and professional projects, I tended to introduce similar abstractions and complexity into my personal projects. Over time, however, I began to appreciate simplicity. The new code for this project is smaller and simpler, and I am quite happy with the end result. You can take a look at the code here: quickqwerty.html . If you want to use the typing tutor, go here: QuickQWERTY . Unfortunately, it does not support keyboard layouts other than QWERTY. When I originally developed this project, my view of the computing world was rather limited. I was not even aware that other keyboard layouts existed. You are, however, very welcome to fork the project and adapt the lessons for other layouts. CFRS[] : This project was my first contribution to the quirky world of esolangs. CFRS[] is an extremely minimal drawing language consisting of only six simple commands: , , , , , and . I developed it in 2023 and have since been maintaining it with occasional bug fixes. This year, I fixed an annoying bug that caused the drawing canvas to overflow on some mobile web browsers. A new demo also arrived from the community this year and has now been added to the community demo page. See Glimmering Galaxy for the new demo. If you want to play with CFRS[] now, visit CFRS[] . FXYT : This is another esolang project of mine. This too is a minimal drawing language, though not as minimal as CFRS[]. Instead, it is a stack-based, postfix canvas colouring language with only 36 simple commands. The canvas overflow bug described in the previous entry affected this project as well. That has now been fixed. Further, by popular demand, the maximum allowed code length has been increased from 256 bytes to 1024 bytes. This means there is now more room for writing more complex FXYT programs. Additionally, the maximum code length for distributable demo links has been increased from 64 bytes to 256 bytes. This allows several more impressive demos to have their own distributable links. Visit FXYT to try it out now. See also the Community Demos to view some fascinating artwork created by the community. Nerd Quiz : This is a new project I created a couple of months ago. It is a simple HTML tool that lets you test your nerdiness through short quizzes. Each question is drawn from my everyday moments of reading, writing, thinking, learning, and exploring. The project is meant to serve as a repository of interesting facts I come across in daily life, captured in the form of quiz questions. Go here to try it out: Nerd Quiz . I hope you will enjoy these little bits of knowledge as much as I enjoyed discovering them. Mark V. Shaney Junior : Finally, I have my own Markov gibberish generator. Always wanted to have one. The project is inspired by the legendary Usenet bot named Mark V. Shaney that used to post messages to various newsgroups in the 1980s. My Markov chain program is written in about 30 lines of Python. I ran it on my 24 years of blog posts consisting of over 200 posts and about 200,000 words and it generated some pretty interesting gibberish. See my blog post Fed 24 Years of My Posts to Markov Model to see the examples. Elliptical Python Programming : If the previous item was not silly enough, this one surely is. Earlier this year, I wrote a blog post describing the fine art of Python programming using copious amounts of ellipses. I will not discuss it further here to avoid spoilers. I'll just say that any day I'm able to do something pointless, whimsical and fun with computers is a good day for me. And it was a good day when I wrote this post. Please visit the link above to read the post. I hope you find it fun. Fizz Buzz with Cosines : Another silly post in which I explain how to compute the discrete Fourier transform of the Fizz Buzz sequence and derive a closed-form expression that can be used to print the sequence. Fizz Buzz in CSS : Yet another Fizz Buzz implementation, this time using just four lines of CSS.
In efficiency-minded code, it is idiomatic to use indexes rather than pointers. Indexes have several advantages: First , they save memory. Typically a 32-bit index is enough, a saving of four bytes per pointer on 64-bit architectures. I haven’t seen this measured, but my gut feeling is that this is much more impactful than it might initially seem. On modern architectures, saving memory saves time (and energy) as well, because the computing bottleneck is often the bit pipe between the memory and the CPU, not the computation per se. Dense data structures use CPU cache more efficiently, removing prohibitive latency of memory accesses. Bandwidth savings are even better: smaller item size obviously improves bandwidth utilization, but having more items in cache obviates the need to use the bandwidth in the first place. Best case, the working set fits into the CPU cache! Note well that memory savings are evenly spread out. Using indexes makes every data structure slightly more compact, which improves performance across the board, regardless of hotspot distribution. It’s hard to notice a potential for such saving in a profiler, and even harder to test out. For these two reasons, I would default to indexes for code where speed matters, even when I don’t have the code written yet to profile it! There’s also a more subtle way in which indexes save memory. Using indexes means storing multiple items in an array, but such dense storage contains extra information in relative positions of the items. If you need to store a list of items, you can often avoid materializing the list of indexes by storing a range “pointing” into the shared storage. Occasionally, you can even do UTF-8 trick and use just a single bit to mark the end of a list. The second benefit of indexes is more natural modeling of cyclic and recursive data structures. Creating a cycle fundamentally requires mutability somewhere (“tying the knot” in Haskell relies on mutability of lazy thunks). This means that you need to make some pointers nullable, and that usually gets awkward even without borrow checker behind your back. Even without cycles and just recursion, pointers are problematic, due to a combination of two effects: The combination works fine at small scale, but then it fails with stack overflow in production every single time, requiring awkward work-arounds. For example, serializes error traces from nested macro expansions as a deeply nested tree of JSON objects, which requires using stacker hack when parsing the output (which you’ll learn about only after crashes in the hands of macro connoisseur users). Finally , indexes greatly help serialization, they make it trivial to communicate data structures both through space (sending a network message) and time (saving to disk and reading later). Indexes are naturally relocatable, it doesn’t matter where in memory they are. But this is just a half of serialization benefit. The other is that, because everything is in few arrays, you can do bulk serialization. You don’t need to write the items one by one, you can directly arrays around (but be careful to not leak data via padding, and be sure to checksum the result). The big problem with “naive” indexes is of course using the right index with the wrong array, or vice verse. The standard solution here is to introduce a newtype wrapper around the raw index. @andrewrk recently popularized a nice “happy accident of language design” pattern for this in Zig. The core idea is to define an index via non-exhaustive : In Zig, designates a strongly-typed collection of integer constants, not a Rust-style ADT (there’s for that). By default an backing integer type is chosen by the compiler, but you can manually override it with syntax: Finally, Zig allows making enums non-exhaustive with . In a non-exhaustive enum, any numeric value is valid, and some have symbolic labels: and builtins switch abstraction level between a raw integer and an enum value. So, is a way to spell “ , but a distinct type”. Note that there’s no strong encapsulation boundary here, anyone can . Zig just doesn’t provide language-enforced encapsulation mechanisms. Putting everything together, this is how I would model n-ary tree with parent pointers in Zig: Some points of note: P.S. Apparently I also wrote a Rust version of this post a while back? https://matklad.github.io/2018/06/04/newtype-index-pattern.html pointers encourage recursive functions, and recursive data structures lead to arbitrary long (but finite) chains of pointers. As usual with indexes, you start with defining the collective noun first, a rather than a . In my experience, you usually don’t want suffix in your index types, so is just , not the underlying data. Nested types are good! feels just right. For readability, the order is fields, then nested types, then functions. In , we have a couple of symbolic constants. is for the root node that is stored first, for whenever we want to apply offensive programing and make bad indexes blow up. Here, we use for “null” parent. An alternative would be to use , but that would waste of space, or making the root its own parent. If you care about performance, its a good idea to sizes of structures, not to prevent changes, but as a comment that explains to the reader just how the large the struct is. I don’t know if I like or more for representing ranges, but I use the former just because the names align in length. Both and are reasonable shapes for the API. I don’t know which one I prefer more. I default to the former because it works even if there are several node arguments.
A month ago, I posted a very thorough analysis on Nano Banana , Google’s then-latest AI image generation model, and how it can be prompt engineered to generate high quality and extremely nuanced images that most other image generations models can’t achieve, including ChatGPT at the time. For example, you can give Nano Banana a prompt with a comical amount of constraints: Nano Banana can handle all of these constraints easily: Exactly one week later, Google announced Nano Banana Pro, another AI image model that in addition to better image quality now touts five new features: high-resolution output, better text rendering, grounding with Google Search, thinking/reasoning, and better utilization of image inputs. Nano Banana Pro can be accessed for free using the Gemini chat app with a visible watermark on each generation, but unlike the base Nano Banana, Google AI Studio requires payment for Nano Banana Pro generations. After a brief existential crisis worrying that my months of effort researching and developing that blog post were wasted, I relaxed a bit after reading the announcement and documentation more carefully. Nano Banana and Nano Banana Pro are different models (despite some using the terms interchangeably), but Nano Banana Pro is not Nano Banana 2 and does not obsolete the original Nano Banana—far from it. Not only is the cost of generating images with Nano Banana Pro far greater, but the model may not even be the best option depending on your intended style. That said, there are quite a few interesting things Nano Banana Pro can now do, many of which Google did not cover in their announcement and documentation. I’ll start off answering the immediate question: how does Nano Banana Pro compare to the base Nano Banana? Working on my previous Nano Banana blog post required me to develop many test cases that were specifically oriented to Nano Banana’s strengths and weaknesses: most passed, but some of them failed. Does Nano Banana Pro fix the issues I had encountered? Could Nano Banana Pro cause more issues in ways I don’t anticipate? Only one way to find out. We’ll start with the test case that should now work: the infamous prompt, as Google’s announcement explicitly highlights Nano Banana Pro’s ability to style transfer. In Nano Banana, style transfer objectively failed on my own mirror selfie: How does Nano Banana Pro fare? Yeah, that’s now a pass. You can nit on whether the style is truly Ghibli or just something animesque, but it’s clear Nano Banana Pro now understands the intent behind the prompt, and it does a better job of the Ghibli style than ChatGPT ever did. Next, code generation. Last time I included an example prompt instructing Nano Banana to display a minimal Python implementation of a recursive Fibonacci sequence with proper indentation and syntax highlighting, which should result in something like: Nano Banana failed to indent the code and syntax highlight it correctly: How does Nano Banana Pro fare? Much much better. In addition to better utilization of the space, the code is properly indented and tries to highlight keywords, functions, variables, and numbers differently, although not perfectly. It even added a test case! Relatedly, OpenAI’s just released ChatGPT Images based on their new image generation model. While it’s beating Nano Banana Pro in the Text-To-Image leaderboards on LMArena , it has difficulty with prompt adherence especially with complex prompts such as this one. Syntax highlighting is very bad, the is missing a parameter, and there’s a random in front of the return statements. At least it no longer has a piss-yellow hue. Speaking of code, how well can it handle rendering webpages given a single-page HTML file with about a thousand tokens worth of HTML/CSS/JS? Here’s a simple Counter app rendered in a browser. Nano Banana wasn’t able to handle the typography and layout correctly, but Nano Banana Pro is supposedly better at typography. That’s a significant improvement! At the end of the Nano Banana post, I illustrated a more comedic example where characters from popular intellectual property such as Mario, Mickey Mouse, and Pikachu are partying hard at a seedy club, primarily to test just how strict Google is with IP. Since the training data is likely similar, I suspect any issues around IP will be the same with Nano Banana Pro—as a side note, Disney has now sued Google over Google’s use of Disney’s IP in their AI generation products. However, due to post length I cut out an analysis on how it didn’t actually handle the image composition perfectly: Here’s the Nano Banana Pro image using the full original prompt: Prompt adherence to the composition is much better: the image is more “low quality”, the nightclub is darker and seedier, the stall is indeed a corner stall, the labels on the alcohol are accurate without extreme inspection. There’s even a date watermark: one curious trend I’ve found with Nano Banana Pro is that it likes to use dates within 2023. The immediate thing that caught my eye from the documentation is that Nano Banana Pro has 2K output (4 megapixels, e.g. 2048x2048) compared to Nano Banana’s 1K/1 megapixel output, which is a significant improvement and allows the model to generate images with more detail. What’s also curious is the image token count: while Nano Banana generates 1,290 tokens before generating a 1 megapixel image, Nano Banana Pro generates fewer tokens at 1,120 tokens for a 2K output, which implies that Google made advancements in Nano Banana Pro’s image token decoder as well. Curiously, Nano Banana Pro also offers 4K output (16 megapixels, e.g. 4096x4096) at 2,000 tokens: a 79% token increase for a 4x increase in resolution. The tradeoffs are the costs: A 1K/2K image from Nano Banana Pro costs $0.134 per image: about three times the cost of a base Nano Banana generation at $0.039. A 4K image costs $0.24. If you didn’t read my previous blog post, I argued that the secret to Nano Banana’s good generation is its text encoder, which not only processes the prompt but also generates the autoregressive image tokens to be fed to the image decoder. Nano Banana is based off of Gemini 2.5 Flash , one of the strongest LLMs at the tier that optimizes for speed. Nano Banana Pro’s text encoder, however, is based off Gemini 3 Pro which not only is a LLM tier that optimizes for accuracy, it’s a major version increase with a significant performance increase over the Gemini 2.5 line. 1 Therefore, the prompt understanding should be even stronger. However, there’s a very big difference: as Gemini 3 Pro is a model that forces “thinking” before returning a result and cannot be disabled, Nano Banana Pro also thinks. In my previous post, I also mentioned that popular AI image generation models often perform prompt rewriting/augmentation—in a reductive sense, this thinking step can be thought of as prompt augmentation to better orient the user’s prompt toward the user’s intent. The thinking step is a bit unusual, but the thinking trace can be fully viewed when using Google AI Studio: Nano Banana Pro often generates a sample 1K image to prototype a generation, which is new. I’m always a fan of two-pass strategies for getting better quality from LLMs so this is useful, albeit in my testing the final output 2K image isn’t significantly different aside from higher detail. One annoying aspect of the thinking step is that it makes generation time inconsistent: I’ve had 2K generations take anywhere from 20 seconds to one minute , sometimes even longer during peak hours. One of the more viral use cases of Nano Banana Pro is its ability to generate legible infographics. However, since infographics require factual information and LLM hallucination remains unsolved, Nano Banana Pro now supports Grounding with Google Search , which allows the model to search Google to find relevant data to input into its context. For example, I asked Nano Banana Pro to generate an infographic for my gemimg Python package with this prompt and Grounding explicitly enabled, with some prompt engineering to ensure it uses the Search tool and also make it fancy : That’s a correct enough summation of the repository intro and the style adheres to the specific constraints, although it’s not something that would be interesting to share. It also duplicates the word “interfaces” in the third panel. In my opinion, these infographics are a gimmick more intended to appeal to business workers and enterprise customers. It’s indeed an effective demo on how Nano Banana Pro can generate images with massive amounts of text, but it takes more effort than usual for an AI generated image to double-check everything in the image to ensure it’s factually correct. And if it isn’t correct, it can’t be trivially touched up in a photo editing app to fix those errors as it requires another complete generation to maybe correctly fix the errors—the duplicate “interfaces” in this case could be covered up in Microsoft Paint but that’s just due to luck. However, there’s a second benefit to grounding: it allows the LLM to incorporate information from beyond its knowledge cutoff date. Although Nano Banana Pro’s cutoff date is January 2025, there’s a certain breakout franchise that sprung up from complete obscurity in the summer of 2025, and one that the younger generations would be very prone to generate AI images about only to be disappointed and confused when it doesn’t work. Grounding with Google Search, in theory, should be able to surface the images of the KPop Demon Hunters that Nano Banana Pro can then leverage it to generate images featuring Rumi, Mira, and Zoey, or at the least if grounding does not support image analysis, it can surface sufficent visual descriptions of the three characters. So I tried the following prompt in Google AI Studio with Grounding with Google Search enabled, keeping it uncharacteristically simple to avoid confounding effects: “Golden” is about Golden Gate Park, right? That, uh, didn’t work, even though the reasoning trace identified what I was going for: Of course, you can always pass in reference images of the KPop Demon Hunters, but that’s boring. One “new” feature that Nano Banana Pro supports is system prompts—it is possible to provide a system prompt to the base Nano Banana but it’s silently ignored. One way to test is to provide the simple prompt of but also with the system prompt of which makes it wholly unambiguous whether the system prompt works. And it is indeed in black and white—the message is indeed silly . Normally for text LLMs, I prefer to do my prompt engineering within the system prompt as LLMs tends to adhere to system prompts better than if the same constraints are placed in the user prompt. So I ran a test of two approaches to generation with the following prompt, harkening back to my base skull pancake test prompt, although with new compositional requirements: I did two generations: one with the prompt above, and one that splits the base prompt into the user prompt and the compositional list as the system prompt. Both images are similar and both look very delicious. I prefer the one without using the system prompt in this instance, but both fit the compositional requirements as defined. That said, as with LLM chatbot apps, the system prompt is useful if you’re trying to enforce the same constraints/styles among arbitrary user inputs which may or may not be good user inputs, such as if you were running an AI generation app based off of Nano Banana Pro. Since I explicitly want to control the constraints/styles per individual image, it’s less useful for me personally. As demoed in the infographic test case, Nano Banana Pro can now render text near perfectly with few typos—substantially better than the base Nano Banana. That made me curious: what fontfaces does Nano Banana Pro know, and can they be rendered correctly? So I gave Nano Banana Pro a test to generate a sample text with different font faces and weights, mixing native system fonts and freely-accessible fonts from Google Fonts : That’s much better than expected: aside from some text clipping on the right edge, all font faces are correctly rendered, which means that specifying specific fonts is now possible in Nano Banana Pro. Let’s talk more about that 5x2 font grid generation. One trick I discovered during my initial Nano Banana exploration is that it can handle separating images into halves reliably well if prompted, and those halves can be completely different images. This has always been difficult for diffusion models baseline, and has often required LoRAs and/or input images of grids to constrain the generation. However, for a 1 megapixel image, that’s less useful since any subimages will be too small for most modern applications. Since Nano Banana Pro now offers 4 megapixel images baseline, this grid trick is now more viable as a 2x2 grid of images means that each subimage is now the same 1 megapixel as the base Nano Banana output with the very significant bonuses of a) Nano Banana Pro’s improved generation quality and b) each subimage can be distinct, particularly due to the autoregressive nature of the generation which is aware of the already-generated images. Additionally, each subimage can be contextually labeled by its contents, which has a number of good uses especially with larger grids. It’s also slightly cheaper: base Nano Banana costs $0.039/image, but splitting a $0.134/image Nano Banana Pro into 4 images results in ~$0.034/image. Let’s test this out using the mirror selfie of myself: This time, we’ll try a more common real-world use case for image generation AI that no one will ever admit to doing publicly but I will do so anyways because I have no shame: I can’t use any of these because they’re too good. One unexpected nuance in that example is that Nano Banana Pro correctly accounted for the mirror in the input image, and put the gray jacket’s Patagonia logo and zipper on my left side. A potential concern is quality degradation since there are the same number of output tokens regardless of how many subimages you create. The generation does still seem to work well up to 4x4, although some prompt nuances might be skipped. It’s still great and cost effective for exploration of generations where you’re not sure how the end result will look, which can then be further refined via normal full-resolution generations. After 4x4, things start to break in interesting ways. You might think that setting the output to 4K might help, but that’s only increases the number of output tokens by 79% while the number of output images increases far more than that. To test, I wrote a very fun prompt: This prompt effectively requires reasoning and has many possible points of failure. Generating at 4K resolution: It’s funny that both Porygon and Porygon2 are prime: Porygon-Z isn’t though. The first 64 prime numbers are correct and the Pokémon do indeed correspond to those numbers (I checked manually), but that was the easy part. However, the token scarcity may have incentivised Nano Banana Pro to cheat: the Pokémon images here are similar-if-not-identical to official Pokémon portraits throughout the years. Each style is correctly applied within the specified numeric constraints but as a half-measure in all cases: the pixel style isn’t 8-bit but more 32-bit and matching the Game Boy Advance generation—it’s not a replication of the GBA-era sprites however, the charcoal drawing style looks more like a 2000’s Photoshop filter that still retains color, and the Ukiyo-e style isn’t applied at all aside from an attempt at a background. To sanity check, I also generated normal 2K images of Pokemon in the three styles with Nano Banana Pro: The detail is obviously stronger in all cases (although the Ivysaur still isn’t 8-bit), but the Pokémon design is closer to the 8x8 grid output than expected, which implies that the Nano Banana Pro may not have fully cheated and it can adapt to having just 31.25 tokens per subimage. Perhaps the Gemini 3 Pro backbone is too strong. While I’ve spent quite a long time talking about the unique aspects of Nano Banana Pro, there are some issues with certain types of generations. The problem with Nano Banana Pro is that it’s too good and it tends to push prompts toward realism—an understandable RLHF target for the median user prompt, but it can cause issues with prompts that are inherently surreal. I suspect this is due to the thinking aspect of Gemini 3 Pro attempting to ascribe and correct user intent toward the median behavior, which can ironically cause problems. For example, with the photos of the three cats at the beginning of this post, Nano Banana Pro unsurprisingly has no issues with the prompt constraints, but the output raised an eyebrow: I hate comparing AI-generated images by vibes alone, but this output triggers my uncanny valley sensor while the original one did not. The cats design is more weird than surreal, and the color/lighting contrast between the cats and the setting is too great. Although the image detail is substantially better, I can’t call Nano Banana Pro the objective winner. Another test case I had issues with is Character JSON. In my previous post, I created an intentionally absurd giant character JSON prompt featuring a Paladin/Pirate/Starbucks Barista posing for Vanity Fair, but also comparing that generation to one from Nano Banana Pro: It’s more realistic, but that form of hyperrealism makes the outfit look more like cosplay than a practical design: your mileage may vary. Lastly, there’s one more test case that’s everyone’s favorite: Ugly Sonic! Nano Banana Pro specifically advertises that it supports better character adherence (up to six input images), so using my two input images of Ugly Sonic with a Nano Banana Pro prompt that has him shake hands with President Barack Obama: Wait, what? The photo looks nice, but that’s normal Sonic the Hedgehog, not Ugly Sonic. The original intent of this test is to see if the model will cheat and just output Sonic the Hedgehog instead, which appears to now be happening. After giving Nano Banana Pro all seventeen of my Ugly Sonic photos and my optimized prompt for improving the output quality, I hoped that Ugly Sonic will finally manifest: That is somehow even less like Ugly Sonic. Is Nano Banana Pro’s thinking process trying to correct the “incorrect” Sonic the Hedgehog? As usual, this blog post just touches the tip of the iceberg with Nano Banana Pro: I’m trying to keep it under 26 minutes this time. There are many more use cases and concerns I’m still investigating but I do not currently have conclusive results. Despite my praise for Nano Banana Pro, I’m unsure how often I’d use it in practice over the base Nano Banana outside of making blog post header images—even in that case, I’d only use it if I could think of something interesting and unique to generate. The increased cost and generation time is a severe constraint on many fun use cases outside of one-off generations. Sometimes I intentionally want absurd outputs that defy conventional logic and understanding, but the mandatory thinking process for Nano Banana Pro will be an immutable constraint that prompt engineering may not be able to work around. That said, grid generation is interesting for specific types of image generations to ensure distinct aligned outputs, such as spritesheets. Although some might criticize my research into Nano Banana Pro because it could be used for nefarious purposes, it’s become even more important to highlight just what it’s capable of as discourse about AI has only become worse in recent months and the degree in which AI image generation has progressed in mere months is counterintuitive. For example, on Reddit, one megaviral post on the /r/LinkedinLunatics subreddit mocked a LinkedIn post trying to determine whether Nano Banana Pro or ChatGPT Images could create a more realistic woman in gym attire. The top comment on that post is “linkedin shenanigans aside, the [Nano Banana Pro] picture on the left is scarily realistic”, with most of the other thousands of comments being along the same lines. If anything, Nano Banana Pro makes me more excited for the actual Nano Banana 2, which with Gemini 3 Flash’s recent release will likely arrive sooner than later. The gemimg Python package has been updated to support Nano Banana Pro image sizes, system prompt, and grid generations, with the bonus of optionally allowing automatic slicing of the subimages and saving them as their own image. Anecdotally, when I was testing the text-generation-only capabilities of Gemini 3 Pro for real-world things such as conversational responses and agentic coding, it’s not discernably better than Gemini 2.5 Pro if at all. ↩︎ Anecdotally, when I was testing the text-generation-only capabilities of Gemini 3 Pro for real-world things such as conversational responses and agentic coding, it’s not discernably better than Gemini 2.5 Pro if at all. ↩︎
In 2022, I wrote a post called The Lotus philosophy applied to blog design , in which I was trying to explain how the Lotus philosophy of lighter cars for improved performance could apply to web design, and to my blog in particular. I wrote: For as long as I can remember, I’ve been a fan of Lotus. From the Esprit featured in The Spy Who Loved Me (1977), the one in the Accolade’s Test Drive video game from 1987, to my fascination with the choices made by the engineers with the 900 kg Elise (and later the Elise CR): Lotus is more than a simple car brand, it is a way to think about product design […] The most acute observers probably noticed my mention of the Lotus Elise CR. This car is, to me at least, a fantastic example of what a company can do when driven by principles and a well laid-out order of priorities. The Elise CR, which stands for Club Racer, was basically a special edition of the regular Lotus Elise, with various modifications aimed for better handling on the track, that was lightened by about 25 kilograms compared to the base car. 1 One may think that a weight reduction of around 3% is nothing, that it doesn’t matter, and that it may not influence performance that much. And to be honest with you, I don’t really know. I just know that I was always fascinated by the engineering that went into saving those 25 kg out of a roughly 900 kg car. Compared to the regular Elise, the CR had its seats fitted with less padding, its floor mats were removed, it had no radio, no A/C, and even the Lotus badge on the back was a sticker instead of using the usual metal letters. The result was a car marginally faster, slightly better to drive, less comfortable, and less practical. If you planned to drive a Lotus Elise on regular roads, you’d be better off with a regular Elise. The Club Racer was a prize among purists, it was a demonstration of what could be done, and I loved that it existed. 2 In its essence, the Club Racer was not about the results on paper or the weight itself, it was about the effort, the craft, and the experience. It was about giving a damn. For a while now, I’ve been generally happy with this site’s design, which feels very much in line with this Lotus philosophy. But there was always an itch that I couldn’t ignore: a Lotus Elise was great, but what I really wanted was a Lotus Elise CR. This is why, in the past couple of… checks notes … weeks, I spent hours and hours giving the Club Racer treatment to this website, for very marginal changes. 3 Now that all of this tedious, frustrating, and abstract work is over, I don’t even know how much weight I saved. Probably the equivalent of the Elise CR’s 25 kg: meaningless to most, meaningful to a few. Like I said, it wasn’t really about the results, but about the effort; it was about getting my hands dirty. Today, I am quite happy with the choices I made and with what I learned in the process. To make sure my project had structure, I needed to identify which were my top 3 priorities, and in which order they needed to be. Obviously, weight saving was one of them, but did I really want to put it above all else? The Lotus Elise CR was about performance and driving experience, not weight saving. Weight saving was just a means to an end. For a blog like mine, the driving experience is obviously the readability, but I also wanted my site to pass the W3C validator, and keep its perfect score on PageSpeed Insights (that’s the performance bit). I ended up with priorities ordered like this: I decided to stick to a serif typeface, to make this website as comfortable as possible to read, just like a page of a paperback novel would be. I have been using STIX Two Text for a while now, and I really like it: it feels a lot like Times New Roman , but improved in every way possible. Not only I think it looks great, but it comes preinstalled on Apple devices, it is open-source, and if a visitor falls back on Times New Roman (via the browser default setting for ), the site maintains enough of the typography to make it just as nice to read: line length, line height, size rendering, etc. Also with readability in mind, I’ve decided to keep the automatic light/dark mode feature, along with the responsive feature for the font size, as it makes text always nicely proportioned compared to the screen size. I certainly could have removed even more than I did, but I wanted to keep the 100 score on PageSpeed Insights and pass the W3C validator . This is why I still have a meta description, for example, and why I use a base64 format for the inline SVG used as the favicon. I kept some of the “branding” elements for good measure, even if what I feel is the visual identity of this site mainly revolves around its lightness. Even a Lotus Elise CR has a coat of paint after all. I could shave even more bytes off this site if the default browser stylesheets weren’t being needlessly updated . But a Club Racer treatment is only fun when talking about weight saving, so let’s get to the good stuff. This is what I removed: Airbags: The HTML tags, as I learned that they are optional in HTML5, as are the tags: If you look at the Elements tab of the browser Web Inspector panel, both are automatically added by the browser, I think. Floor mats: The quotation marks in most of the elements in the but also on some the permanent links (I didn’t go as far as reworking the Markdown parser of Eleventy to get rid of them in all attributes, but on the homepage and other pages, each link is now 2 bytes lighter — at least before Brotli compression and other shenanigans). Power steering: The line height setting for headings. Foam: The padding left and right for mobile view. Sound isolation: A lot of unnecessary nodes in the homepage, now leaner and lighter, at the expense of extra CSS: very worth it. This includes the summaries for Blend of links posts that felt very repetitive. Air conditioning: The little tags around the “by” of the header to make it 16% smaller. I liked you guys, but you had to go. Radio: The highlight colour, used since 2020 on this site, mostly as the bottom border colour for links: it felt distracting and didn’t work well in dark mode. Metal logo: for headings. This CSS feature makes titles look great, but for most of them it wasn’t even needed on desktop. And a bunch of other little things that I mostly forgot (I should have kept a log). 4 To you dear readers, if you’re not reading this in an RSS reader, this site won’t feel any faster than before. It won’t even look better. If anything, it will look slightly worse and for that, I’m sorry. Well, not really: I’m actually very happy about what has changed, and I think it will make this site easier to maintain, and easier to be proud of. On top of the weight-saving, I also worked on improving my local Eleventy setup, reducing dependencies and the number of node modules. I’ve mentioned this on my Now page , but the site now compiles in 1.5 second on my Intel-Core-i5-powered MacBook Air, which is roughly 2–3 times faster than before. I guess this is when you have an underpowered engine that weight-saving and simplifications are the most noticeable. More noticeable than on the website that’s for sure. I hope that when I finally upgrade my computer, probably next March, I won’t get fooled by the hugely improved chips on the newer Macs, to the point of forgetting Colin Chapman: Adding power makes you faster on the straights; subtracting weight makes you faster everywhere. Happy holidays everyone. I found a great review here , in French. ↩︎ Lotus nowadays surely doesn’t look like a brand Colin Chapman would recognise. ↩︎ I thought it would only take a couple of days, but here I am, three weeks later; This was a rather enjoyable rabbit hole. ↩︎ To help me in some of the decisions, I asked a lot of questions to ChatGPT. It sometimes gave me very useful answers, but sometimes it felt like I could have just tossed a coin instead. Also, I was starting to get very annoyed at the recurring “ ah, your question is the classic dilemma between Y and Z ”. ↩︎ Driving experience / Readability Performance / W3C validation & PageSpeed Insights scores Weight saving Airbags: The HTML tags, as I learned that they are optional in HTML5, as are the tags: If you look at the Elements tab of the browser Web Inspector panel, both are automatically added by the browser, I think. Floor mats: The quotation marks in most of the elements in the but also on some the permanent links (I didn’t go as far as reworking the Markdown parser of Eleventy to get rid of them in all attributes, but on the homepage and other pages, each link is now 2 bytes lighter — at least before Brotli compression and other shenanigans). Power steering: The line height setting for headings. Foam: The padding left and right for mobile view. Sound isolation: A lot of unnecessary nodes in the homepage, now leaner and lighter, at the expense of extra CSS: very worth it. This includes the summaries for Blend of links posts that felt very repetitive. Air conditioning: The little tags around the “by” of the header to make it 16% smaller. I liked you guys, but you had to go. Radio: The highlight colour, used since 2020 on this site, mostly as the bottom border colour for links: it felt distracting and didn’t work well in dark mode. Metal logo: for headings. This CSS feature makes titles look great, but for most of them it wasn’t even needed on desktop. And a bunch of other little things that I mostly forgot (I should have kept a log). 4 I found a great review here , in French. ↩︎ Lotus nowadays surely doesn’t look like a brand Colin Chapman would recognise. ↩︎ I thought it would only take a couple of days, but here I am, three weeks later; This was a rather enjoyable rabbit hole. ↩︎ To help me in some of the decisions, I asked a lot of questions to ChatGPT. It sometimes gave me very useful answers, but sometimes it felt like I could have just tossed a coin instead. Also, I was starting to get very annoyed at the recurring “ ah, your question is the classic dilemma between Y and Z ”. ↩︎
mdBook is a tool for easily creating books out of Markdown files. It's very popular in the Rust ecosystem, where it's used (among other things) to publish the official Rust book . mdBook has a simple yet effective plugin mechanism that can be used to modify the book output in arbitrary ways, using any programming language or tool. This post describes the mechanism and how it aligns with the fundamental concepts of plugin infrastructures . mdBook's architecture is pretty simple: your contents go into a directory tree of Markdown files. mdBook then renders these into a book, with one file per chapter. The book's output is HTML by default, but mdBook supports other outputs like PDF. The preprocessor mechanism lets us register an arbitrary program that runs on the book's source after it's loaded from Markdown files; this program can modify the book's contents in any way it wishes before it all gets sent to the renderer for generating output. The official documentation explains this process very well . I rewrote my classical "nacrissist" plugin for mdBook; the code is available here . In fact, there are two renditions of the same plugin there: Let's see how this case study of mdBook preprocessors measures against the Fundamental plugin concepts that were covered several times on this blog . Discovery in mdBook is very explicit. For every plugin we want mdBook to use, it has to be listed in the project's book.toml configuration file. For example, in the code sample for this post , the Python narcissist plugin is noted in book.toml as follows: Each preprocessor is a command for mdBook to execute in a sub-process. Here it uses Python, but it can be anything else that can be validly executed. For the purpose of registration, mdBook actually invokes the plugin command twice . The first time, it passes the arguments supports <renderer> where <renderer> is the name of the renderer (e.g. html ). If the command returns 0, it means the preprocessor supports this renderer; otherwise, it doesn't. In the second invocation, mdBook passes some metadata plus the entire book in JSON format to the preprocessor through stdin, and expects the preprocessor to return the modified book as JSON to stdout (using the same schema). In terms of hooks, mdBook takes a very coarse-grained approach. The preprocessor gets the entire book in a single JSON object (along with a context object that contains metadata), and is expected to emit the entire modified book in a single JSON object. It's up to the preprocessor to figure out which parts of the book to read and which parts to modify. Given that books and other documentation typically have limited sizes, this is a reasonable design choice. Even tens of MiB of JSON-encoded data are very quick to pass between sub-processes via stdout and marshal/unmarshal. But we wouldn't be able to implement Wikipedia using this design. This is tricky, given that the preprocessor mechanism is language-agnostic. Here, mdBook offers some additional utilities to preprocessors implemented in Rust, however. These get access to mdBook 's API to unmarshal the JSON representing the context metadata and book's contents. mdBook offers the Preprocessor trait Rust preprocessors can implement, which makes it easier to wrangle the book's contents. See my Rust version of the narcissist preprocessor for a basic example of this. Actually, mdBook has another plugin mechanism, but it's very similar conceptually to preprocessors. A renderer (also called a backend in some of mdBook 's own doc pages) takes the same input as a preprocessor, but is free to do whatever it wants with it. The default renderer emits the HTML for the book; other renderers can do other things. The idea is that the book can go through multiple preprocessors, but at the end a single renderer. The data a renderer receives is exactly the same as a preprocessor - JSON encoded book contents. Due to this similarity, there's no real point getting deeper into renderers in this post. One in Python, to demonstrate how mdBook can invoke preprocessors written in any programming language. One in Rust, to demonstrate how mdBook exposes an application API to plugins written in Rust (since mdBook is itself written in Rust).
Gitanjali Venkatraman does wonderful illustrations of complex subjects (which is why I was so happy to work with her on our Expert Generalists article). She has now published the latest in her series of illustrated guides: tackling the complex topic of Mainframe Modernization In it she illustrates the history and value of mainframes, why modernization is so tricky, and how to tackle the problem by breaking it down into tractable pieces. I love the clarity of her explanations, and smile frequently at her way of enhancing her words with her quirky pictures. ❄ ❄ ❄ ❄ ❄ Gergely Orosz on social media Unpopular opinion: Current code review tools just don’t make much sense for AI-generated code When reviewing code I really want to know: Some people pushed back saying they don’t (and shouldn’t care) whether it was written by a human, generated by an LLM, or copy-pasted from Stack Overflow. In my view it matters a lot - because of the second vital purpose of code review. When asked why do code reviews, most people will answer the first vital purpose - quality control. We want to ensure bad code gets blocked before it hits mainline . We do this to avoid bugs and to avoid other quality issues, in particular comprehensibility and ease of change. But I hear the second vital purpose less often: code review is a mechanism to communicate and educate. If I’m submitting some sub-standard code, and it gets rejected, I want to know why so that I can improve my programming. Maybe I’m unaware of some library features, or maybe there’s some project-specific standards I haven’t run into yet, or maybe my naming isn’t as clear as I thought it was. Whatever the reasons, I need to know in order to learn. And my employer needs me to learn, so I can be more effective. We need to know the writer of the code we review both so we can communicate our better practice to them, but also to know how to improve things. With a human, its a conversation, and perhaps some documentation if we realize we’ve needed to explain things repeatedly. But with an LLM it’s about how to modify its context, as well as humans learning how to better drive the LLM. ❄ ❄ ❄ ❄ ❄ Wondering why I’ve been making a lot of posts like this recently? I explain why I’ve been reviving the link blog. ❄ ❄ ❄ ❄ ❄ Simon Willison describes how he uses LLMs to build disposable but useful web apps These are the characteristics I have found to be most productive in building tools of this nature: His repository includes all these tools, together with transcripts of the chats that got the LLMs to build them. ❄ ❄ ❄ ❄ ❄ Obie Fernandez : while many engineers are underwhelmed by AI tools, some senior engineers are finding them really valuable. He feels that senior engineers have an oft-unspoken mindset, which in conjunction with an LLM, enables the LLM to be much more valuable. Levels of abstraction and generalization problems get talked about a lot because they’re easy to name. But they’re far from the whole story. Other tools show up just as often in real work: ❄ ❄ ❄ ❄ ❄ Emil Stenström built an HTML5 parser in python using coding agents, using Github Copilot in Agent mode with Claude Sonnet 3.7. He automatically approved most commands. It took him “a couple of months on off-hours”, including at least one restart from scratch. The parser now passes all the tests in html5lib test suite. After writing the parser, I still don’t know HTML5 properly. The agent wrote it for me. I guided it when it came to API design and corrected bad decisions at the high level, but it did ALL of the gruntwork and wrote all of the code. I handled all git commits myself, reviewing code as it went in. I didn’t understand all the algorithmic choices, but I understood when it didn’t do the right thing. Although he gives an overview of what happens, there’s not very much information on his workflow and how he interacted with the LLM. There’s certainly not enough detail here to try to replicate his approach. This is contrast to Simon Willison (above) who has detailed links to his chat transcripts - although they are much smaller tools and I haven’t looked at them properly to see how useful they are. One thing that is clear, however, is the vital need for a comprehensive test suite. Much of his work is driven by having that suite as a clear guide for him and the LLM agents. JustHTML is about 3,000 lines of Python with 8,500+ tests passing. I couldn’t have written it this quickly without the agent. But “quickly” doesn’t mean “without thinking.” I spent a lot of time reviewing code, making design decisions, and steering the agent in the right direction. The agent did the typing; I did the thinking. ❄ ❄ Then Simon Willison ported the library to JavaScript : Time elapsed from project idea to finished library: about 4 hours, during which I also bought and decorated a Christmas tree with family and watched the latest Knives Out movie. One of his lessons: If you can reduce a problem to a robust test suite you can set a coding agent loop loose on it with a high degree of confidence that it will eventually succeed. I called this designing the agentic loop a few months ago. I think it’s the key skill to unlocking the potential of LLMs for complex tasks. Our experience at Thoughtworks backs this up. We’ve been doing a fair bit of work recently in legacy modernization (mainframe and otherwise) using AI to migrate substantial software systems. Having a robust test suite is necessary (but not sufficient) to making this work. I hope to share my colleagues’ experiences on this in the coming months. But before I leave Willison’s post, I should highlight his final open questions on the legalities, ethics, and effectiveness of all this - they are well-worth contemplating. The prompt made by the dev What corrections the other dev made to the code Clear marking of code AI-generated not changed by a human A single file: inline JavaScript and CSS in a single HTML file means the least hassle in hosting or distributing them, and crucially means you can copy and paste them out of an LLM response. Avoid React, or anything with a build step. The problem with React is that JSX requires a build step, which makes everything massively less convenient. I prompt “no react” and skip that whole rabbit hole entirely. Load dependencies from a CDN. The fewer dependencies the better, but if there’s a well known library that helps solve a problem I’m happy to load it from CDNjs or jsdelivr or similar. Keep them small. A few hundred lines means the maintainability of the code doesn’t matter too much: any good LLM can read them and understand what they’re doing, and rewriting them from scratch with help from an LLM takes just a few minutes. A sense for blast radius. Knowing which changes are safe to make loudly and which should be quiet and contained. A feel for sequencing. Knowing when a technically correct change is still wrong because the system or the team isn’t ready for it yet. An instinct for reversibility. Preferring moves that keep options open, even if they look less elegant in the moment. An awareness of social cost. Recognizing when a clever solution will confuse more people than it helps. An allergy to false confidence. Spotting places where tests are green but the model is wrong.
I wrote about JustHTML yesterday - Emil Stenström's project to build a new standards compliant HTML5 parser in pure Python code using coding agents running against the comprehensive html5lib-tests testing library. Last night, purely out of curiosity, I decided to try porting JustHTML from Python to JavaScript with the least amount of effort possible, using Codex CLI and GPT-5.2. It worked beyond my expectations. I built simonw/justjshtml , a dependency-free HTML5 parsing library in JavaScript which passes 9,200 tests from the html5lib-tests suite and imitates the API design of Emil's JustHTML library. It took two initial prompts and a few tiny follow-ups. GPT-5.2 running in Codex CLI ran uninterrupted for several hours, burned through 1,464,295 input tokens, 97,122,176 cached input tokens and 625,563 output tokens and ended up producing 9,000 lines of fully tested JavaScript across 43 commits. Time elapsed from project idea to finished library: about 4 hours, during which I also bought and decorated a Christmas tree with family and watched the latest Knives Out movie. One of the most important contributions of the HTML5 specification ten years ago was the way it precisely specified how invalid HTML should be parsed. The world is full of invalid documents and having a specification that covers those means browsers can treat them in the same way - there's no more "undefined behavior" to worry about when building parsing software. Unsurprisingly, those invalid parsing rules are pretty complex! The free online book Idiosyncrasies of the HTML parser by Simon Pieters is an excellent deep dive into this topic, in particular Chapter 3. The HTML parser . The Python html5lib project started the html5lib-tests repository with a set of implementation-independent tests. These have since become the gold standard for interoperability testing of HTML5 parsers, and are used by projects such as Servo which used them to help build html5ever , a "high-performance browser-grade HTML5 parser" written in Rust. Emil Stenström's JustHTML project is a pure-Python implementation of an HTML5 parser that passes the full html5lib-tests suite. Emil spent a couple of months working on this as a side project, deliberately picking a problem with a comprehensive existing test suite to see how far he could get with coding agents. At one point he had the agents rewrite it based on a close inspection of the Rust html5ever library. I don't know how much of this was direct translation versus inspiration (here's Emil's commentary on that ) - his project has 1,215 commits total so it appears to have included a huge amount of iteration, not just a straight port. My project is a straight port. I instructed Codex CLI to build a JavaScript version of Emil's Python code. I started with a bit of mise en place. I checked out two repos and created an empty third directory for the new project: Then I started Codex CLI for GPT-5.2 like this: That flag is a shortcut for , which is every bit as dangerous as it sounds. My first prompt told Codex to inspect the existing code and use it to build a specification for the new JavaScript library: I reviewed the spec, which included a set of proposed milestones, and told it to add another: Here's the resulting spec.md file . My request for that initial version became "Milestone 0.5" which looked like this: Milestone 0.5 — End-to-end smoke parse (single valid document) Then I told it: And off it went. The resulting code appeared to work so I said: I ran and created a private GitHub repository for this project at this point, and set up the local directory to push to that remote. Here's that initial push . Then I told it: And that was almost it! I set my laptop to not fall asleep and left it to its devices while we went off to buy a Christmas tree. The "commit and push often" meant I could monitor its progress on my phone by refreshing the commit log on GitHub . I was running this against my $20/month ChatGPT Plus account, which has a five hour token allowance window for Codex CLI. That ran out at 6:35pm and Codex paused, so I waited until the reset point at 7:14pm and typed: At 9:30pm it declared itself done with the following summary message: As a finishing touch, I had it add a playground interface so I could try out the new library in my browser. I prompted: It fetched my existing JustHTML playground page ( described here ) using and built a new file that loaded the new JavaScript code instead. This worked perfectly . I enabled GitHub Pages for my still-private repo which meant I could access the new playground at this URL: https://simonw.github.io/justjshtml/playground.html All it needed now was some documentation: You can read the result here . We are now at eight prompts total, running for just over four hours and I've decorated for Christmas and watched Wake Up Dead Man on Netflix. According to Codex CLI: My llm-prices.com calculator estimates that at $29.41 if I was paying for those tokens at API prices, but they were included in my $20/month ChatGPT Plus subscription so the actual extra cost to me was zero. I'm sharing this project because I think it demonstrates a bunch of interesting things about the state of LLMs in December 2025. I'll end with some open questions: You are only seeing the long-form articles from my blog. Subscribe to /atom/everything/ to get all of my posts, or take a look at my other subscription options . Implement the smallest end-to-end slice so the public API is real early: returns a tree with the expected tag structure and text nodes. returns and is empty for this valid input. Add (no deps) that runs the example and asserts the expected structure/output. Gate: passes. Frontier LLMs really can perform complex, multi-hour tasks with hundreds of tool calls and minimal supervision. I used GPT-5.2 for this but I have no reason to believe that Claude Opus 4.5 or Gemini 3 Pro would not be able to achieve the same thing - the only reason I haven't tried is that I don't want to burn another 4 hours of time and several million tokens on more runs. If you can reduce a problem to a robust test suite you can set a coding agent loop loose on it with a high degree of confidence that it will eventually succeed. I called this designing the agentic loop a few months ago. I think it's the key skill to unlocking the potential of LLMs for complex tasks. Porting entire open source libraries from one language to another via a coding agent works extremely well. Code is so cheap it's practically free. Code that works continues to carry a cost, but that cost has plummeted now that coding agents can check their work as they go. We haven't even begun to unpack the etiquette and ethics around this style of development. Is it responsible and appropriate to churn out a direct port of a library like this in a few hours while watching a movie? What would it take for code built like this to be trusted in production? Does this library represent a legal violation of copyright of either the Rust library or the Python one? Even if this is legal, is it ethical to build a library in this way? Does this format of development hurt the open source ecosystem? Can I even assert copyright over this, given how much of the work was produced by the LLM? Is it responsible to publish software libraries built in this way? How much better would this library be if an expert team hand crafted it over the course of several months?