I’ve seen a few articles about going buildless with programming Web applications.
a couple weeks ago I saw someone’s idea for Vanilla Prime, which is an opinion on how to make Vanilla JS somewhat optimal and more pleasant
and yesterday I saw Going Buildless, a blog post which evaluates how far you can go without any builds.
as a big fan of simplicity, I really like that there’s a corner of the Web which values simplicity over 100% convenience.
a lot of focus in modern day Web development seems to be on delivering apps ASAP but implemented inefficiently, and then trying to iron over that with complex tooling such as minifiers.
I think there’s a genuine use case for that—if so many people are using it, that means there’s a demand for it. but I honestly don’t like the complexity. I deal with enough of it at work, and I wouldn’t wanna have to understand a complex build toolchain for my little homegrown website.
I mean, if the browser does it for you… then it’s probably smart to conserve that energy, and do something more interesting!
so I built my own static website generator!
but I promise this blog post is not just about that—I’ve written about it before, and I don’t really see value in me writing yet another blog post in the vein of “here’s an overview of my statically generated blog’s tech stack okay bye,”
I Ate My Themplate Engine, and why not quite buildless
static generation is super cool, because all you have to end up writing is a single function that deletes the existing output directory, creates a new one, and fills it in with a bunch of files.
in case of the treehouse, the sources for the statically generated files are
.tree
files, a bunch of Handlebars templates, and a bunch of assorted static files, including JavaScript.
but honestly… I don’t really like that choice of templating engine, or rather templating engine implementation! the Rust implementation in particular has been kind of a pain in the butt, because it tries very hard to be multithreading friendly. that of course means some annoying
Send + Sync
bounds…my personal opinion is that it would be neater if there was an initial setup step, which freezes some parts of the registry—these parts become immutable after you set them up, and then you can send them out to threads—for example, behind an
Arc
. afterwards, you construct the render-time part, which requires mutable access—and therefore every thread that needs to render templates gets its own instance of that.
at some point I wanted to add OpenGraph metadata, so that branches you link to via permalinks such as this one would get nice embeds in Discord and other chat apps, including the branch’s text—which prompted me to drop the
try_files
andproxy_pass
to an axum server instead. but it was fun and simple while it lasted!
not incremental by design
this is the idea I built the treehouse generator in mind with—computers are pretty heckin’ fast. for the 56 unique
.tree
-derived pages the treehouse generates, most of the time is not actually spent parsing and generating text files, but rather… reading metadata for image files, so that I can generate properwidth="" height=""
attributes on<img>
elements.I don’t have any profiling infrastructure set up just yet, but I suspect it’s something with the
image
crate doing more work than it needs to in order to obtain size metadata.
if I wanted to implement incremental builds, I feel like the dependency tracking would get pretty hellish pretty quickly.
say a
.tree
file uses theinclude_static
template directive to include some file into itself. now in addition to compiling the.tree
file when it changes, I’d also need to recompile the.tree
file when thatinclude
d_static
file changes too—and that sort of dependency tracking is ripe for bugs as the codebase grows more complex!
Vanilla JS
so I decided to go with Vanilla JS for the treehouse.
in fact, not just for the treehouse. the app I’m building currently is also completely Vanilla JS, for the exact same reasons.
I’ve written about my feelings towards JavaScript before though, so I won’t repeat myself too much here.
live reloading
in debug mode, I have the treehouse reload itself on change automatically.
I actually learned the Web ecosystem has such cool mechanisms for app development back when I was trying out React.js for a little project of mine. I didn’t end up building anything in the end because the project idea just didn’t end up vibing with me, but it provided me with lots of useful knowledge. especially related to how frickin’ cool React is in terms of developer experience.
here’s the script I use for rkgk, hosted under
static/live-reload.js
:// NOTE: The server never fulfills this request, it stalls forever. // Once the connection is closed, we try to connect with the server until we establish a successful // connection. Then we reload the page. await fetch("/auto-reload/stall").catch(async () => { while (true) { try { let response = await fetch("/auto-reload/back-up"); if (response.status == 200) { window.location.reload(); break; } } catch (e) { await new Promise((resolve) => setTimeout(resolve, 100)); } } });
on the Rust side, these
/stall
and/back-up
endpoints are implemented like so:use std::time::Duration; use axum::{routing::get, Router}; use tokio::time::sleep; pub fn router<S>() -> Router<S> { let router = Router::new().route("/back-up", get(back_up)); #[cfg(debug_assertions)] let router = router.route("/stall", get(stall)); router.with_state(()) } #[cfg(debug_assertions)] async fn stall() -> String { loop { // Sleep for a day, I guess. Just to uphold the connection forever without really using any // significant resources. sleep(Duration::from_secs(60 * 60 * 24)).await; } } async fn back_up() -> String { "".into() }
in the treehouse, I use
tower-livereload
, but I wouldn’t recommend it.also,
tower-livereload
also has a couple disadvantages compared to the solution I’ve shown here.it cats the JavaScript payload directly to your server’s
Content-Type: text/html
responses, which produces invalid HTML. as far as I can tell, all major browsers seem to parse it correctly, but it’s a pretty ugly hack nevertheless.it’s 500 lines of dependency for something you can do with 12 lines of JavaScript, 3 lines of HTML, and 28 lines of Rust!
to trigger the reloads, I use
cargo-watch
, mostly because it’s really convenient.cargo watch -x run
and you’re done! your site will now reload when you
:w
.
Web Component shenanigans
as the treehouse uses Vanilla JS, I needed some solution for building reusable components that wasn’t React. luckily for me, I already knew about Web Components—in particular, custom elements.
custom elements are just that—custom HTML elements that you can include in your page. according to the MDN docs, these have two flavours:
customized built-in elements, which extend any built-in element. these are applied using the
is=""
attribute on a base built-in element, like<li is="your-element"></li>
.unfortunately customized built-in elements are practically useless, because Safari doesn’t implement them, and doesn’t even plan to do so…
but that doesn’t diminish the usefulness of autonomous custom elements either way!
to implement a custom element, I usually use this pattern:
class YourElement extends HTMLElement { // You can use the constructor of a custom element to require some // parameters from other JS code. // Note that adding *required* parameters here makes your element // practically unusable from HTML, because there's no way to pass them in! constructor() { super(); } connectedCallback() { // Read attributes and add children here. } } // Choose a different prefix; `owo` is just an example to get you going. // You *must* choose a prefix, because custom elements require at least one dash `-` in their names. customElements.define("owo-your-element", YourElement);
and off we go!
I’ve found a couple useful idioms for working with custom elements.
DOM construction: a useful idiom is passing
createElement
toappendChild
. this allows you to append a new element to the component (or really anything, it’s a useful idiom overall).I usually follow it up with adding a CSS class for easy styling, naming the CSS class after the object field name on the JavaScript side.
this.textArea = this.appendChild(document.createElement("textarea")); this.textArea.classList.add("text-area");
it’s kind of verbose, but if you don’t like it, you’re free to wrap that up in a helper function—I personally don’t mind, since it’s simple code that I usually follow this up with more initialization logic.
patching everything together: I usually use plain DOM
Event
s for event handlers that don’t need to return any data back to the component. I prefix their names with.
to not confuse them with built-in DOM events such asmousedown
.it’s pretty convenient to construct events with
Object.assign
, too.this.dispatchEvent( Object.assign( new Event(".codeChanged"), { newCode }, ), );
you can wrap the event construction in a function too if you mind the verbosity much, but again—I personally don’t.
cache bust a little, cache bust some more
cache busting is a super cool technique for ensuring the browser does not download assets that haven’t changed. essentially, for each asset you serve to the user, you compute a hash that’s then included in all URLs referencing the asset from your website.
and that’s it! the actual value of the
?cache
parameter is never interpreted by anyone, anyhow. it’s only there so that whenever something does change, we change the URL, and the browser thinks that “hey, that’s a different asset! gotta download it.”that way, the browser only ever downloads files that changed since your last visit.
initially I implemented cache busting for most static assets, because that’s pretty easy to do: add a helper to your templating engine that can derive these
?cache
-augmented URLs by computing a hash of the linked file.in my case I use BLAKE3—as indicated by the
b3-
prefix—but the choice of hash function shouldn’t matter that much; I just chose a fast crypto hash for lower likelihood of collisions. which would of course cause assets not to get redownloaded, if that ever happened.
the far bigger challenge was making this work for JavaScript files.
CSS doesn’t refer to too many assets—there’s fonts and a couple images in that one blog post’s stylesheet, which will probably never change, so we can hardcode those.
treehouse is built on ES modules. as I mentioned before, I don’t bundle or minify anything, because HTTP/2 makes using plain ES modules quite efficient, as long as you import them all from your main HTML file. the problem is that if you’re referring to modules like this…
import "treehouse/vendor/codejar.js";
how the heck are you going to add that
?cache
parameter in there?as you can see in the previous example, the treehouse had already used import maps by that point. for those of you who don’t know, these are handy little bits of JSON that tell the browser’s JavaScript runtime where to source your modules from.
so, “sure,” I say. “I’ll have to include the whole import map verbatim in each
.html
file. no big deal, we don’t cache.html
s anyways…”it’s kind of sad, because it’d allow me to cache linked branches (such as this one)—I’d love it if I could get rid of the
Loading...
text entirely if you’ve ever loaded a branch, but while that is feasible, it’s probably going to benefit snappiness less than I’d like, due to import maps influencing the hash of each.html
file. and therefore each time I add a.js
file, all cached HTML files would get busted…oh well.
and with an import map implemented, I go look at my glorious generated sources, and see… that my import map keys change every build
this is a really stupid thing, but Rust (and other languages) randomise the ordering of hash maps to prevent hash DoS attacks, which means you can’t use them to generate deterministic data. such as a file that shouldn’t change across rebuilds!
so I swapped the
std::collections::HashMap
with aindexmap::IndexMap
, sorted it after generation, and everything’s working smoothly
Djot down some notes
I’d initially chosen Markdown as my website’s markup language, simply because I was already familiar with it, and because I’ve seen the Rust ecosystem had a nice parser for it that seemed pretty customizable.
as time went on though, I discovered another light markup language: Djot, made by the same person who made Markdown, with lots of lessons learned from his previous attempt.
the one thing that sold me on Djot was how easy it is to create custom HTML elements. for instance, it has syntax for a div:
::: class-goes-here I'm in a div! :::
or a span:
[I'm in a span!]{.whatever}
which is really cool if you’re doing a lot of bespoke markup in your blog posts.
ultimately, the switch mostly came down to converting
*abc*
into_abc_
,**abc**
into*abc*
, and~~abc~~
into{~abc~}
, as well as fencing any inline HTML off with some=html
, as well as fixing up some links—because Djot forces you to use two pairs of[]
, like[here's a link that's defined elsewhere][]
, instead of just one like Markdown.and in the end, it’s interesting the switch made parsing slightly faster, and the HTML generation code slightly cleaner!
the HTML generation code got cleaner, because the crate I’m using—
jotdown
—does not use a callback for filling in broken links. a pattern that’s best known under the moniker “yeah, don’t do that” in the Rust world.I found the easiest way of going about writing your HTML generator is copying the built-in one in your light markup parsing library of choice, and adjusting it to your needs. so yeah. mine’s mostly stolen code.
aside, but there’s one frustrating thing about the Rust ecosystem: why does everything have to be a trait? I don’t think I’ve declared a trait once in my recent projects—both in the treehouse and rkgk, yet I constantly see examples of unnecessary traits such as this one in the wild.
a
rustc
stability benchmarkthis is a weird one, but: sometimes
rustc
will choke up on evaluating obligations for the implementation ofUnpin
forSemaBranch
, and just… die.what’s funny is that it dies dropping a
rustc-ice-YYYY-MM-DDTHH_MM_SS-NNNNN.txt
file into your current working directory, and combined withcargo-watch
this has the super funny effect of generating tens of those files in the treehouse repo.I have forgotten to remove these before checking in at least once before. I don’t think I ever ended up pushing that commit though, so I can’t show you… but you can see the ICE I have stored in my local Git history here.
I unfortunately don’t have a consistent repro on this, though
rustc
has told me this is a known issue. it’s weird it only happens with the treehouse. like there’s a ghost inhabiting it…