Warn­ing! Your devicePixelRatio is not 1 and you have JavaScript dis­abled! (Or if this mes­sage is not in a red box, your brows­er doesn't even sup­port ba­sic CSS and you can prob­a­bly safe­ly ig­nore this warn­ing. You'll en­counter way more sub­op­ti­mal things in that case...) Since HTML is as brain­dead as it can get, pix­el graph­ics might look off with­out javashit workarounds. Try to get a non-HiDPI screen or en­able javashit for best re­sults.

2026 updates

Cre­at­ed: 1768509728 (2026-01-15T20:42:08Z), Up­dat­ed: 1768778270 (2026-01-18T23:17:50Z), 5382 words, ~23 min­utes

Tags: , ,

This post is part of se­ries blog up­date: 2023, , 2026

Yes, I know, it's on­ly Jan­u­ary, but I couldn't stay put, and fixed some of the worst prob­lems on my site.

git(ea)#

There has been a gitea in­stance run­ning at git.nep­tards.moe for some time, but it hasn't been up­dat­ed in a long time. Back at the time I used it, be­cause it was like a light­weight GitLab, and I al­so thought I would use its fea­tures. But of course, as time went on, gitea be­came more and more bloat­ed, but the fi­nal nail in the cof­fin was when the chink UI Nazis on the project start­ed mak­ing javashit manda­to­ry to use the site. Lat­er there was a forge­jo fork, I briefly looked at it, but it was ob­vi­ous this fork had po­lit­i­cal rea­sons (the Chi­nese on gitea did some shady shit, but hon­est­ly I didn't look too deep in­to this), and not be­cause they were hav­ing any prob­lem of turn­ing gitea in­to a javashit-heavy bloat­ed mess. On the al­ter­na­tives side, there's source­hut, but any­one who looked at the source­hut in­stal­la­tion man­u­al can tell it's a hy­per-duper scal­able mi­croser­vice crap re­quir­ing you to set up 14 gazil­lion ser­vices just to run a ba­sic git host­ing, and I'm not sure you can run it with­out buy­ing a serv­er park first. So in the end, I was stuck with run­ning some old ver­sion of gitea be­cause I didn't want to up­date to the new­er abom­i­na­tions.

Fast for­ward last year, when I ran in­to stag­it, a sta­t­ic site gen­er­a­tor for git repos­i­to­ries. You give it a git re­po, and it gen­er­ates a bunch of sta­t­ic HTML files, and it's done. No need to run a dae­mon, to dy­nam­i­cal­ly gen­er­ate pages, just plain HTML files served by ng­inx (or what­ev­er your choice of HTTP serv­er is). I liked the idea, and start­ed think­ing about this whole git serv­er, and I frankly come to the con­clu­sion that I don't re­al­ly need a fan­cy soft­ware forge on my site. No one con­tributes code any­way. The few bug re­ports I got on gitea usu­al­ly dis­ap­peared be­cause it al­ways broke mail send­ing. So maybe the sta­t­ic files gen­er­at­ed by stag­it is enough.

Well, two prob­lems. The man­u­al states it's not suit­able for large repos­i­to­ries, and it's not an over­state­ment. The first time I tried run­ning it, it start­ed pro­cess­ing my QEMU fork, and af­ter a cou­ple (tens) of min­utes it crashed be­cause it ran out of disk space... The blog post says "the cache (-c flag) or (-l maxlim­it) is a workaround for this in some cas­es", but note the readme in the git mir­ror doesn't men­tion the -l flag for this prob­lem, on­ly -c. And the man page states "Write a max­i­mum num­ber of com­mits to the log.html file on­ly. How­ev­er the com­mit files are writ­ten as usu­al." Yes, this is the cor­rect an­swer. All -l flag does is not link­ing the com­mit diffs in­to the com­mit list page. All the com­mit diffs files are still gen­er­at­ed, they just be­come dan­gling files with­out any ref­er­ence to them, thus point­less­ly wast­ing disk space and gen­er­a­tion time. Se­ri­ous­ly, what kind of de­ranged lu­natic id­iot you have to be to think this fea­ture makes any sense?! What's the point? It's worse than im­pe­r­i­al units! So in the end I end­ed up fork­ing stag­it to fix this prob­lem, and be able to gen­er­ate pages for the git re­pos I have.

Sec­ond, fig­ur­ing out how to clone the re­pos. The stag­it au­thor's site gives you a git:// URL. Yes, the old un­en­crypt­ed pro­to­col which was an in­sane choice for any­thing be­sides lo­cal LAN even 10 years ago. You have the SSH pro­to­col, but it's gen­er­al­ly on­ly used for au­then­ti­cat­ed ac­cess. So what re­mains is the HTTP pro­to­col, which is ac­tu­al­ly two dif­fer­ent pro­to­cols, the dumb and the smart one. The dumb pro­to­col is pret­ty sim­ple, there's a script you have to run af­ter each push (just set up a post-up­date hook, and it will be tak­en care of), but then you can just serve the git re­po through a sim­ple HTTP serv­er. Sim­ple to set­up on the serv­er, but on­ly do this if you hate your users with a pas­sion. The prob­lem is, to clone a re­po like this, the client first had to down­load the refs (a sin­gle file), then for each ref down­load the cor­re­spond­ing com­mit. First it tries to down­load the com­mit ob­ject, if it's suc­cess­ful from it it gets the tree and par­ent ids, which have to be down­loaded as well. The client prac­ti­cal­ly has to make a tree walk, and it's not re­al­ly par­al­leliz­able. And this goes on un­til the client hits a 404, be­cause it means the ob­ject is in a pack. So at this point the client has to down­load the the in­dex­es of every pack file (which some­times end up be­ing com­pa­ra­ble sizes if not big­ger than the ac­tu­al packs), to de­ter­mine which pack file it has to down­load. And here lies an­oth­er prob­lem, the client has to down­load the whole pack­file, even if it on­ly needs a few ob­jects from it. Try to down­load a re­po with a lot of loose ob­jects from a serv­er on the oth­er side of the Earth, and you'll be long­ing at sub­ver­sion's painful­ly slow check­out process.

So the oth­er op­tion is the smart pro­to­col. If you have GitLab, Gitea or some­thing sim­i­lar, it has it's own smart pro­to­col im­ple­men­ta­tion, and every­thing works fine. But with­out? The stan­dard an­swer to this ques­tion is git-http-back­end. Yes, a CGI script, which means I can't use it di­rect­ly with ng­inx. I looked around on the in­ter­net for al­ter­na­tives, but what I found were all ei­ther aban­doned, still ex­per­i­men­tal, or writ­ten in some hip­ster lan­guage where fig­ur­ing out how to down­load and com­pile them is more trou­ble than get­ting ng­inx to ex­e­cute CGI. So in the end, I stayed with the CGI script. For­tu­nate­ly, some­one made a CGI mod­ule for ng­inx, so I didn't have to mess with fcgi­wrap like I did it once in the past (even though it's not packed in Alpine Lin­ux, so I had to make a cus­tom APKBUILD for it).

But fig­ur­ing out how to set things up is a dif­fer­ent beast. First, un­less you want every­thing pub­licly ac­ces­si­ble, you need to put a file called git-daemon-export-ok in each git re­po you want to ex­port. I al­so want­ed to use the method where stag­it pages URL and the git clone URL are the same, de­scribed un­der the "To serve gitweb at the same url" part in the man­u­al, and the ac­cel­er­at­ed part be­low. Un­for­tu­nate­ly, it's for Apache on­ly, so I had to get cre­ative. In the end I end­ed up with some­thing like this:

location / {
    root /path/to/html;
    add_header cache-control "no-cache" always;
}

root /path/to/git;
location ~ ^/[^/]+/[^/]+/(?:HEAD|info/refs|objects/info/packs|git-upload-pack|git-upload-archive)$ {
    cgi pass /usr/libexec/git-core/git-http-backend;
}
location ~ ^/[^/]+/[^/]+/objects/ {
}

There are two main changes com­pared to what's in the man page. First, I don't for­ward git-receive-pack to the CGI script. I nev­er in­tend to push to a HTTP URL, ng­inx's user doesn't have write ac­cess to the git re­pos any­way, but still, min­i­miz­ing the at­tack sur­face seems like a wise de­ci­sion. Sec­ond, I sim­pli­fied the regex around the objects dir, on­ly objects/info/packs have to be for­ward­ed to git-http-server (this is a file which is nor­mal­ly gen­er­at­ed by git update-server-info, so it can be miss­ing/out­dat­ed if the post-up­date hook is not set up), every­thing else is just plain ob­ject/pack files. Just in this case if you have a re­po with­out git-daemon-export-ok, but an at­tack­er knows some ob­ject/pack id, they will be able to down­load it.

Of course, when I tried it it didn't work. First, on Alpine Lin­ux, I had to in­stall the git-daemon pack­age, git-http-backend is not in the nor­mal git pack­age. But then I still got HTTP 500 er­rors, with­out any­thing in the logs. In the end I ran nginx in strace with fol­low mode, and I fi­nal­ly found the prob­lem. Of course, it's CVE-2022-24765 still fuck­ing over every­one's life. I don't know in which sane sys­tem (Win­dows doesn't qual­i­fy) do you end up stor­ing git re­pos in a dir, where an un­trust­ed user might have ac­cess to one of it's par­ent di­rec­to­ries, but what­ev­er. Plus the er­ror help mes­sage (which is on­ly vis­i­ble in­side strace's out­put for max­i­mum user friend­li­ness) on­ly men­tions a git com­mand to ex­e­cute to fix it. Yeah, I'm go­ing to fig­ure out how to ex­e­cute a git com­mand as my web serv­er. If you don't want to fig­ure out which ran­dom di­rec­to­ry will be your HOME dir, you can cre­ate (or ap­pend to) the file /etc/gitconfig:

[safe]
directory = /path/to/repos/*

One small note, here * works like **, so this will match every re­po un­der /path/to/repos.

nanoc#

In last year's up­date, I al­ready hint­ed at my dis­con­tent with nanoc, which should be called slowc. Dur­ing the Christ­mas I had some un­in­tend­ed time where I had noth­ing bet­ter to do than think­ing about this whole shit, and af­ter my brain was over­flow­ing with ideas I knew I had to do some­thing. My first at­tempt was to uti­lize ninja to build the site. Sure, it doesn't have a web serv­er like nanoc or can't watch di­rec­to­ries for a change, but those should be rel­a­tive­ly easy to add with a Ru­by script, right? But I didn't get so far, prob­lems man­i­fest­ed way ear­li­er. Get­ting back to the hash­es top­ic, the first pro­gram I made was a lit­tle Ru­by script to cal­cu­late the hash­es need­ed for the file­names. I gen­er­at­ed a ba­sic nin­ja build file, ran it... and us­ing all 32 threads of my CPU, it took more time than nanoc did it on a sin­gle thread. Uh­hh, just per­fect, turns out the Ru­by in­ter­preter's start up time is not ex­act­ly neg­li­gi­ble. I had a sec­ond job (more on it a bit lat­er) which on­ly called a few shell com­mands, it was much faster. Should I rewrite it in C? But think­ing about it, I have a lot of (over 2000) small files, so batch­ing them to­geth­er could help a lot. I tried it, and it in­deed helped, but then wel­come to nin­ja's lim­i­ta­tions. Un­like make, ninja saves the com­mand line of the ex­e­cut­ed pro­gram, and if it changes it re­builds the out­put, even if the in­puts are un­changed. Great, this is what you want 99.9% of the times, but here this would mean I need a way to get a "sta­ble" batch­ing, so if I add/re­move a sin­gle file, on­ly a few batch­es need to be re­build, not half of them. Oh and since the gen­er­a­tor is re-run every time the list of files change, this batch­ing al­go­rithm has to be state­less. I'm pret­ty sure some smart boy al­ready came up with a clever al­go­rithm, but I had an­oth­er prob­lem, and these two prob­lems even­tu­al­ly led to me aban­don­ing ninja.

ninja was orig­i­nal­ly made to build C/C++ pro­grams. Gen­er­al­ly, your build tar­gets have one or more in­puts, one or more out­puts, and dur­ing the ex­e­cu­tion the com­pil­er will out­put ex­tra in­puts (head­ers) it us­es in some for­mat. Please note this as­sumes those ex­tra in­puts are ex­ist­ing when the build starts, they're not gen­er­at­ed by the build script. If they are, you usu­al­ly have to man­u­al­ly add these gen­er­at­ed files as (prob­a­bly or­der-on­ly) de­pen­den­cies. Lat­er ninja got a dyn­dep fea­ture, which al­lows you to have a tar­get gen­er­ate the list of in­puts/out­puts used by oth­er tar­gets. Note the word "oth­er". It must be still kin­da gen­er­at­ed up­front. There was a con­ver­sa­tion about dy­nam­ic im­plic­it out­puts, some­one made a PR about it which got nowhere, some­one else con­tin­ued it, but it al­so got nowhere. My workaround for this was to have the Ru­by script cal­cu­late the hash, write it to a file (with a fixed name), and al­so gen­er­ate a dyn­dep file, then have a sec­ond tar­get which does the ac­tu­al link­ing, some­thing along the lines of

ln -sf ../../../$in "out/c/$$(cat $hash)/"$base && touch $out

The ex­tra touch at the end is need­ed be­cause tasks in the dyn­dep files are iden­ti­fied by the first (nor­mal) out­put of the tar­get, so it needs a dum­my out­put with a fixed name too. This worked, but it's dis­gust­ing and in­ef­fi­cient.

But this is still not enough, as I can't gen­er­ate build tar­gets dy­nam­i­cal­ly. The cur­rent site has two "dy­nam­ic" file tar­gets, and by dy­nam­ic I mean you can't just tell the set of out­put file names from just the names of the in­put files: the in­dex pages and tag list. For the in­dex pages, you need to get the (meta­da­ta) of all posts, sort them by cre­ation date, and make chunks of length 5. Now I could kind of workaround this, be­cause of tag list and se­ries in­fo and the likes, I al­ready need­ed some­thing which gets the meta­da­ta of all posts and com­putes these in­fo, and the num­ber of in­dex pages need­ed on­ly de­pend on the num­ber of posts, so I could just make a "gen­er­ate page i of in­dex" script, and have it fig­ure out through im­plic­it de­pen­den­cies which posts it ex­act­ly needs. But tag list is not so easy. A sin­gle post can have an ar­bi­trary num­ber of tags, and they can over­lap, and their file­name de­pend on the tag name not just an in­dex. Maybe I could have said, fuck it, I make 100 tag tar­gets, and if some­how my site ends up with more than 100 tag I need to ad­just a con­stant in my nin­ja gen­er­a­tor script, but at this point I was so full of ninja's lim­i­ta­tions, that I de­cid­ed to roll my own.

nanoc replacement, take two#

At this point, from my ninja ex­per­i­ments and my pre­vi­ous ex­pe­ri­ence with nanoc, I knew Ru­by in­ter­preter's start­up time is not ze­ro, so spawn­ing a new ru­by in­stance for every lit­tle task is not the cor­rect ap­proach. The ques­tion is, how to do par­al­lel pro­cess­ing in Ru­by? There's the dread­ed GIL called GVL in MRI Ru­by, which means de­spite hav­ing threads in the lan­guage, they can't run par­al­lel. Yes I know about JRuby and TruffleRuby, but they strug­gle with new Ru­by fea­tures like non-block­ing fibers, and JRuby is ex­treme­ly slow to start up (mul­ti­ple sec­onds from what I re­mem­ber). (TruffleRuby with­out JVM is sup­posed to be faster, but hon­est­ly I nev­er tried it. No Gen­too ebuild, and I don't want to go back to hav­ing to mess with rvm.) There are Rac­tors, but... Oh well... I can't write a sta­tus up­date with­out a healthy rant, right?

Some peo­ple say Rac­tors have a chick­en/egg prob­lem, but IMHO they have a be­ing fun­da­men­tal­ly bro­ken prob­lem. What Rac­tors try to achieve is hav­ing an ac­tor-style frame­work where you have no shared da­ta (so no lock­ing, and dead­locks and race con­di­tion and shit), but with­out hav­ing you to have com­plete­ly sep­a­rate in­stances (like work­ers in JavaScript). Sounds nice in the­o­ry, if you look at it from 1000 km, but up­on a clos­er in­spec­tion it's a hor­ri­ble idea. Since Ru­by doesn't re­al­ly have con­stants, try­ing to share code be­tween Rac­tors means try­ing to share qua­si-con­stant da­ta be­tween Rac­tors, while lit­ter­ing Rac­tors with lots of stu­pid re­stric­tions. For ex­am­ple, this works:

X = 1
Ractor.new { puts X }
X = 2

and de­pend­ing on the sched­ul­ing, you might get 1 or 2 print­ed to the con­sole. But if you have X = "foo" (with­out frozen_string_literal), you get an IsolationError. If you use $x = 1, you al­so get an IsolationError. Or you know, you can do def x in­stead of as­sign­ing to a con­stant to pre­vent get­ting warn­ings about al­ready ini­tial­ized con­stants. You can­not ac­cess class vari­ables (@@foo) from Rac­tors at all, but you can read (but not write) in­stance vari­ables on class­es/mod­ules if they're share­able (which in the end is prac­ti­cal­ly the same thing). So what we have here, is some­thing which com­bines the bad as­pects of threads (hard to rea­son about pro­gram state, dead­locks, race con­di­tions) with ac­tor-like frame­works (lot of copy­ing be­cause the lack of shared state, prob­lem­at­ic if you have non se­ri­al­iz­able/trans­fer­able ob­jects), with ze­ro of ad­van­tages of ei­ther ap­proach, while as an added bonus, breaks about 99.9% of ex­ist­ing Ru­by code. Al­so look at this ar­ti­cle, where the au­thor tries to im­ple­ment im­ple­ment a very ba­sic Rac­tor aware con­nec­tion pool in about two pages of code. Pure in­san­i­ty. For com­par­i­son, this is how it would look like with threads:

class Pool
  def initialize
    @queue = Thread::Queue.new
  end

  def checkout
    @queue.pop true
  rescue ThreadError
    Whatever.new
  end

  def checkin(conn) = @queue << conn
end

And with async:

class Pool
  def initialize
    @pool = []
  end

  def checkout = @pool.pop || Whatever.new
  def checkin(conn) = @pool << conn
end

(Well, be­cause of the GVL prob­a­bly even this works with Threads.) They should have ei­ther went on to have an abil­i­ty to fire up com­plete sep­a­rate Ru­by in­ter­preter on a new thread (like JavaScript work­ers), or get rid of GVL (like what Python or the al­ter­nate Ru­by im­ple­men­ta­tions did). The Rac­tors in their cur­rent form are a com­plete bull­shit. But any­way, the fi­nal nail in the cof­fin of Rac­tors was this:

[4] pry(main)> Ractor.new { OpenSSL::Digest::SHA256.digest 'foo' }
(pry):4: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.
#<Thread:0x00007f6df708d4c0 run> terminated with exception (report_on_exception is true):
(pry):4:in 'block in __pry__': defined with an un-shareable Proc in a different Ractor (RuntimeError)
=> #<Ractor:#2 (pry):4 terminated>

Even Ru­by's built in li­braries are not Rac­tor com­pat­i­ble yet. (A small note, the in­fo above is cor­rect as of Ru­by 3.4, I saw the fresh­ly re­leased Ru­by 4.0 had some Rac­tor im­prove­ments, but most of what I wrote above still stands.)

So back on how to do par­al­lel pro­cess­ing in Ru­by ques­tion. Seems like the an­swer is still the same as 20 years ago, by fork­ing (or spawn­ing) ex­tra Ru­by process­es. How do you com­mu­ni­cate with ex­ter­nal process­es in a script­ed lan­guage (so no things like boost::interprocess)? Most like­ly pipes or sock­ets. The prob­lem is I have many work­ers, and while hav­ing a ded­i­cat­ed pipe to each work­er and de­cid­ing which work­er gets which job in the mas­ter process works, I a) didn't want to im­ple­ment it, b) this in­ad­ver­tent­ly leads to bot­tle­necks if many work­ers fin­ish their job at the same time, as the mas­ter is sin­gle thread­ed. What was my so­lu­tion? Sock­ets can be set do data­gram mode, and if you do it, the ker­nel will guar­an­tee each data­gram will be re­ceived by ex­act­ly one work­er in whole. Sounds good? Al­most.

First prob­lem is process man­age­ment. When hav­ing mul­ti­ple process­es talk­ing to each oth­er, I like to de­sign them in a way so if any of the process­es crash­es, the oth­ers shut down too, and not leav­ing lin­ger­ing process­es be­hind, like what chrome al­ways do. This means the mas­ter kills the child process­es be­fore ex­it, and the child process­es need some way to de­tect the dis­ap­pear­ance of the mas­ter process too. With pipes, if you try to read af­ter the write end is closed, you get an EOF, so you can de­tect a dy­ing par­ent eas­i­ly. The same is true if you cre­ate a stream unix do­main sock­et pair, but I need data­grams. And data­grams are con­nec­tion­less, so read­ing on a sock­et where the oth­er end is al­ready closed re­sults in an in­fi­nite wait. Use­ful, right? To solve this prob­lem you need to use SOCK_SEQPACKET, which is a weird hy­brid be­tween stream and data­gram mode, and I didn't even know about its ex­is­tence be­fore I ran in­to this prob­lem. For unix do­main sock­ets, it's pret­ty much the same as data­gram mode, ex­cept it's con­nec­tion-based, so you can de­tect when the oth­er peer dies.

Sec­ond is, by chang­ing to data­grams, I lost the abil­i­ty to send ar­bi­trar­i­ly long mes­sages. There's a glob­al­ly set­table size of the buffer used by the Lin­ux ker­nel for unix do­main sock­ets, if your mes­sage doesn't fit in it, send­ing fails, and this lim­it is around 200 kiB, which is not a small size, but not a lot ei­ther. For­tu­nate­ly, unix do­main sock­ets al­low pass­ing file de­scrip­tors around, so I can write large da­ta in­to a file and pass the FD around. But do I re­al­ly need to cre­ate temp files? Can't it be some kind of shared mem­o­ry? While look­ing at the io-mem­o­ry project, I re­al­ized the so­lu­tion: memfd_create! It's Lin­ux on­ly, but with a sin­gle syscall you get some­thing like cre­at­ing a tem­po­rary file on a tmpfs, with­out need­ing a spe­cif­ic tmpfs mount. I didn't end up us­ing io-mem­o­ry project, be­cause it was made for shared mem­o­ry, and in my case I need to mar­shal Ru­by ob­jects, which I can do di­rect­ly in­to an FD, but not in­to an mmapped mem­o­ry re­gion.

Porting the shit over#

Af­ter I had a work­ing mul­ti­process work queue, I start­ed writ­ing the ba­sics of a task sched­uler and tasks to cal­cu­late the hash­es of sta­t­ic files. I'm not go­ing to write a lot about this, you just have to wait un­til the de­pen­den­cies are done and start build­ing. The prob­lem­at­ic part is fix­ing all the weird edge cas­es, the main rea­son I want­ed to use ninja orig­i­nal­ly, to save me from this... But af­ter I had the ba­sic sys­tem up and run­ning, it blew away nin­ja's process fork­ing mad­ness, be­ing way faster than it, and I didn't even have to im­ple­ment any kind of batch­ing! So it's time to mull over how to ac­tu­al­ly build this site.

Sta­t­ic files are the eas­i­est. Just hash them, and link them in­to the out­put dir at c/<hash>/<filename>. No de­pen­den­cies, easy peasy. Next the CSS files. I ac­tu­al­ly run CSS files through eru­bi to do some sub­sti­tu­tions, most im­por­tant­ly re­solve links in url()s. So to com­pile CSS files, I need hash­es. Which hash­es? Good ques­tion, I can on­ly tell it af­ter I ex­e­cut­ed the Ru­by script in the eru­bi tem­plates. Nanoc han­dled these de­pen­den­cies more or less au­to­mat­i­cal­ly, but here I'd need to de­clare them up­front. To avoid it, I went with ninja's idea on how to han­dle gen­er­at­ed head­ers, put the task gen­er­at­ing them in­to an or­der-on­ly de­pen­den­cy, and dur­ing the C build, cap­ture the ac­tu­al list of used head­ers from the com­pil­er. Of course, here I don't gen­er­ate a make com­pat­i­ble file of de­pen­den­cies just to parse it in the next step, but the the­o­ry is the same. On the first build, all hash­es have to be done, on sub­se­quent builds, on­ly changes in the ac­tu­al­ly used hash­es caus­es a re­build. And what's the name of the out­put? Since I want­ed CSS files to be cacheable like sta­t­ic as­sets, they need a path which change every time the CSS changes. Ac­tu­al­ly, in case of nanoc, I had a dis­gust­ing hack, where I tried to ex­tract the de­pen­den­cies from the CSS us­ing a regex, since in nanoc you have to tell the des­ti­na­tion file­names up­front, but here's no need for it, I just hash the out­put CSS file. This al­so means if some­thing in the in­put changes which dis­ap­pears af­ter mini­fi­ca­tion, it won't cre­ate a new file.

I al­so fi­nal­ly man­aged to in­te­grate rollup prop­er­ly. In­stead of in­vok­ing the rollup ex­e­cutable di­rect­ly, I have a small javashit file which us­es rollup's API to com­pile the bun­dle and out­put a JSON on std­out. Do­ing this has two ad­van­tages: first, this way I can get a list of de­pen­den­cies, which is cru­cial for cor­rect sub­se­quent re­builds. Sec­ond, this way I can hash the JavaScript out­put from Ru­by and put it in­to the cor­rect dir with­out hav­ing to deal with tem­po­rary files. And I al­so got rid an­oth­er dis­gust­ing hack here. Right now, the javashit bun­dle has two file­name ref­er­ences (a CSS and an im­age file). With nanoc, I on­ly knew these names af­ter the nanoc rules were processed, but I had to ex­e­cute rollup in ad­vance. My so­lu­tion were to put some ug­ly place­hold­ers in the JS code, then re­place it with an ug­ly regex in nanoc. For­tu­nate­ly this is no longer need­ed, an­oth­er win for san­i­ty!

Then on­to the posts. First prob­lem is the large amount of helpers I've writ­ten, I had to ad­just them to work with­out nanoc. In­ter­est­ing­ly, even though nanoc comes with a bunch of helpers, I bare­ly used any of them, so most changes were re­quired to fig­ure out how to get all the in­fo my pre­vi­ous helpers got from @items. And a few ran­dom prob­lems popped up. For every video on the blog, I need to ex­tract the mime types (so the brows­er can tell which for­mats are sup­port­ed), the length of the video, and for every im­age that ap­pears di­rect­ly in the page, their size (if not over­rid­den). Videos are a bit sim­pler, I need the mime types for every video file, so I can get them in an­oth­er task, eas­i­ly par­al­leliz­able and cacheable. How­ev­er I don't need the length for every video file, the dif­fer­ent for­mats of the same video have the same length, but for now I went with on­ly get­ting the lengths of the HQ for­mats. Every video is avail­able in HQ for­mat, and I don't think this will change. The im­ages are dif­fer­ent though. I re­al­ly don't know the list of im­ages I need to get the size of in ad­vance, and there's a bunch of im­ages I don't need to get the size of, so with im­ages I went in a sim­i­lar di­rec­tion as in nanoc, while com­pil­ing the mark­down files, cal­cu­late im­ages sizes of the need­ed im­ages on the fly, and cache them.

But I'm not done. As you can see at the top or bot­tom of this page, there's a list of oth­er posts in the same se­ries. To gen­er­ate it, I need to know the meta­da­ta of every oth­er posts on the site. (Ac­tu­al­ly I fig­ured out this part while think­ing about the ninja im­ple­men­ta­tion, but it didn't get any­where.) So there's an ex­tra task which gets all posts on the site, pars­es the JSON meta­da­ta at the be­gin­ning of the file, merges them to­geth­er, and stores them for the lat­er mark­down tasks. I have a prob­lem with this ap­proach though, as now any change in any post trig­gers the re­build­ing of this task. For­tu­nate­ly if the out­put is un­changed, the build script prunes this task from the de­pen­den­cy tree (some­thing sim­i­lar to what ninja does), so it's not too bad, but I'm still think­ing about abol­ish­ing this nanoc style meta­da­ta in the file crap. Al­so this is the same task which takes care of the in­dex and tag lists pages. In ninja this is im­pos­si­ble, but here I can just gen­er­ate a new task while build­ing.

One last thing I want­ed to take care of is com­press­ing text files. I nev­er fig­ured out how to do it with nanoc ef­fi­cient­ly, all I had was a sim­ple bash script which rsynced the out­put files to a dif­fer­ent di­rec­to­ry and com­pressed them as need­ed. In my new build script, I have a pret­ty gener­ic "task with de­pen­den­cies" im­ple­men­ta­tion, so I just need­ed a few new new tasks and done. Au­to­mat­i­cal­ly par­al­lelized and de­pen­den­cy checked. Sweet.

Now the ques­tion is, was it worth it? A full re­build with nanoc and emp­ty caches took about 22.03 s, as re­port­ed by nanoc. How­ev­er this met­ric can be mis­lead­ing, as nanoc on­ly mea­sures the time it spent ac­tu­al­ly build­ing the site, the to­tal time re­quired (as mea­sured by time nanoc) is more like 22.64 s. Yes, nanoc needs about 600 ms to start up. With filled page caches, the same op­er­a­tion takes 19.76 (20.28) s. And this is af­ter a lot of op­ti­miza­tions I did last year, be­fore it was over 45 s. But what was the most an­noy­ing is when I edit­ed a sin­gle file, and it took ages for the re­sults to show up. So I bench­marked no-op re­builds, they were 3.77 (4.39) s un­cached and 3.62 (4.14) s cached. 4 sec­onds just to fig­ure out you don't have to do any­thing! So, what about my own script, ex­e­cut­ed on the same com­put­er, but not re­strict­ed to a sin­gle thread? A full un­cached re­build is 5.65 (6.33) s and cached is 4.28 (4.84) s. A full re­build is al­most as fast as a no-op re­build with nanoc! And of course, the num­bers for no-op re­builds are: 0.34 (0.90) s un­cached, 0.27 (0.80) s cached. I save the md file in emacs, look at my brows­er, and the re­sults are there, not wait 4–5 sec­onds then the re­sults are there.

How­ev­er, I'm com­par­ing ap­ples with or­anges here. The build times for my script in­clude rollup and com­press­ing the out­put files, while nanoc num­bers doesn't. So I made a large com­mand line, com­bin­ing rollup, nanoc, and my com­press script. Build times here are 33.92 s un­cached, 31.36 s cached, and no-op builds are 6.28 / 6.02 s. Now, this takes longer to fig­ure out noth­ing has changed than my script needs to to a full re­build. Not bad.

One thing still an­noy­ing me though, is the start­up time of the Ru­by in­ter­preter. As you can see it's around 500–600 ms for both, which makes sense as both so­lu­tions use rough­ly the same set of un­der­ly­ing li­braries. For­tu­nate­ly, it's not a huge prob­lem if I use watch mode, which I gen­er­al­ly do (al­so some­thing I im­ple­ment­ed, but let's not go in­to it...). I made an at­tempt of try­ing to lazy load the li­braries on­ly when need­ed, but then as I ex­pect­ed it, it made no-op re­builds faster and full re­builds slow­er. In the end, I de­cid­ed against go­ing this way, made the code more messy with ques­tion­able ad­van­tages. If I want per­for­mance im­prove­ments, this is what I should look in­to:

Running tasksSVG

At first, all the hash jobs starts. Around the 1 s mark, hash­es start fin­ish­ing. How­ev­er it still takes about 1.5 s un­til all the hash­es fin­ish, as there's an al­most 1 GiB video, which in it­self takes about 0.5 s to hash, and the or­der­ing of the tasks are less than op­ti­mal. Then the CSS files can start, then rollup, and it's not fast, it needs about 1.1 s, and on­ly af­ter can it start com­pil­ing the pages. I should prob­a­bly switch over to xxHash, as hash­ing the same file on­ly needs about 0.1 s us­ing XXH3_64bits while still hav­ing enough bits to gen­er­ate a 10 char­ac­ter id. But as I men­tioned in 2025 up­dates, this would change every file's lo­ca­tion, so I on­ly did this par­tial­ly: there's an ug­ly regex in the code match­ing the lo­ca­tions of the old ar­ti­cles, files there are hashed us­ing SHA-256, every­thing else us­ing xxHash. My plan is to re­move di­rec­to­ries from this reg­exp if I change any old post, or al­ter­na­tive­ly wait un­til I have a bet­ter rea­son to make such a break­ing change. Fur­ther­more, right now every CSS/JS/mark­down file de­pends on every hash, but in prac­tice this is not need­ed, right now none of my posts need any file from an­oth­er post's di­rec­to­ry, and I don't see any rea­son why would I change this. Well, maybe next time.

Up­date 2026-01-18: And I couldn't hold back. I did the per-di­rec­to­ry hash­es I men­tioned above, and al­so gave some tasks high­er pri­or­i­ty, this way a full cached build takes 3.27 (3.83) s. Faster than nanoc no-op build! I'm not post­ing an­oth­er graph, since it looks the sim­i­lar, ex­cept the val­ley is a bit short­er. Rollup just takes longer then all the hash­ing. One thing I could look in­to here is us­ing swc, which is much faster than type­script, but there's a catch: it doesn't do type­check­ing. So in this case I'd still need to run type­script man­u­al­ly, the on­ly thing I'd win is mov­ing type­script check­ing out of the crit­i­cal path. Maybe some day. End up­date.

Blog is open source#

With the above men­tioned changed, I de­cid­ed to make the blog fi­nal­ly open source, it's avail­able on my git serv­er. I don't want to write too much about here, check the git if you're in­ter­est­ed, but I have to warn you it can be messy. Al­so it us­es git-an­nex to store large me­dia files, make sure you have git-an­nex in­stalled and fol­low the readme to get the as­sets, if you ac­tu­al­ly want to com­pile the site. But if all you're in­ter­est­ed in is how the site is be­ing made, you prob­a­bly don't need it, just don't be sur­prised if im­age/video files end up as sym­links to some weird­ly named nonex­is­tent files.

This post is part of se­ries blog up­date: 2023, , 2026