The Random Link In The Age Of Static Sites
I just released Ga11ery, a minimalist kit for sharing photos, illustrations, or your web comic. I figured a lot of folks are diving into their art right now, as a way to find purpose and peace during isolation. I wanted to make it as easy as possible to share the output.
Ga11ery-spawned sites are built with Eleventy and managed using Netlify CMS. Using static site generation presented an interesting challenge when it came to implementing the ubiquitous web comic “random link”.
I love the random link, and shudder to think how many times I've pressed any one of the following iconic examples. Can you identify them all? Answers to follow.
(Answers: Reading from top left, xkcd, Poorly Drawn Lines, Gun Show, Saturday Morning Breakfast Cereal)
Sites like xkcd use REST and a /random/comic
endpoint. When you follow this URL, the server is told to find a random resource and return it. Actually, that's how most web comics seem to do it. But you need things like a database and a server-side language running (to do the randomization part) for that. It's very 2008, and there have to be client/server “handshakes” and all that sort of touchy-feely stuff we’ve been habituated to abhor during this time of social distancing.
How could I achieve the same thing for a static site? And could I make it more efficient?
For Ga11ery, I was looking into Eleventy’s pagination feature for the previous/next/first/last navigation. Honestly, I found it a bit confusing, which was undoubtedly mostly my own issue. But then Mia Suzanne kindly stepped in with some lateral thinking. On her personal site, she used a template filter to provision her previous and next links instead.
At build time, and for each page, the filter code identifies the appropriate previous and next links based on the index of said page in the collection array. A snippet of Mia’s code for illustration:
if (pageIndex !== -1) {
return {
prev: collection[pageIndex - 1] || null,
next: collection[pageIndex + 1] || null,
};
}
To provide a basic random link, I just had to use a bit of Math.random
code in there (after removing the current page from the collection, or course):
const others = collection.filter(item => item.url !== page.url);
const random = others[Math.floor(Math.random() * others.length)];
I like this, because it does the job without relying on server logic or client-side JavaScript. The joy of running JavaScript at build time instead! But there is a potential issue: If page (a) “randomly” ends up pointing at page (b) and page (b) “randomly” ends up pointing at page (a), the user gets stuck in a loop between the two pages.
So, instead, what if I treated this okay-but-less-than-ideal implementation for the basic experience and used (wait for it) progressive enhancement to improve upon it? There’s no harm in using a little client-side JavaScript so long as…
- Things still work pretty well where it doesn’t load
- It doesn’t depend on a massive library being parsed just to do its one little job
It didn’t take long for me to realize I could embed a comma-separated string of all the collection pages’ URLs into my built HTML (allLinks
in the code to follow). In the browser, this string could then be converted into an array. No, this isn’t some weird ES2027 syntax; it’s just an ugly hybrid of JS and nunjucks:
const allLinks = '{{ allLinks }}'.split(',');
const randomLink = allLinks[Math.floor(Math.random() * allLinks.length)];
Then I thought, “oh shit”. If that collection got really large—into its thousands, say—I‘d be embedding a huge string of crap into every page of the site. Not to mention my little Math.random
line would have to work on a large set of data. Arguably, this would still be more efficient than the whole PHP/handshake/relay/SQL rigamarole, but do I really need all this data just to avoid creating that infinite loop?
No. I only need an array of two or three. I spoiled myself with five in the end. Back in the filter code:
const shuffle = (array) => {
const clone = array.slice(0);
for (let i = clone.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[clone[i], clone[j]] = [clone[j], clone[i]];
}
return clone;
}
const others = collection.filter(item => item.url !== page.url);
const randomFive = shuffle(others).slice(0, 5).map(other => other.url).join(',');
Probably the most intensive operation here is the shuffle
function. But that happens at build time, so I don’t care: the user is unaffected. On the client, I just need to pick one random item from five (big deal) and enhance the random link to point to that location rather than the build-rendered one. At first, I did it like this:
<script type="module">
(function() {
const randomBtn = document.querySelector('.random');
const randomFive = '{{ links.randomFive }}'.split(',');
const randomNew = randomFive[Math.floor(Math.random() * randomFive.length)];
randomBtn.addEventListener('click', e => {
e.preventDefault();
window.location.href = randomNew;
});
})();
</script>
- The good parts: It’s a tiny, dependency free script, using
type="module"
so it only executes in modern browsers. No transpilation or polyfilling required. Older browsers ignoretype="module"
JavaScript and comfortably fall back to the basic, build-provisioned experience. - The bad parts: When a user hovers on the link, they'll see a location in the corner of their browser that is deceptive. The
window.location.href
line will take them somewhere else. Also, I’d really rather avoid adding an event listener if I can help it.
The shorter, better version just replacing the link’s href
:
<script type="module">
(function() {
const randomBtn = document.querySelector('.random');
const randomFive = '{{ links.randomFive }}'.split(',');
const randomNew = randomFive[Math.floor(Math.random() * randomFive.length)];
randomBtn.href = randomNew;
})();
</script>
I’m confident this solution is more performant than using REST, PHP, and database queries (or similar) and I’m certain it’s more efficient than rendering the entire site on the client. The reason I wrote it up is because it’s a good example of the kind of problem I like to solve, and how I like to solve it. Build-rendered beats server-rendered beats client-rendered. Where you delegate the complex logic and data-lifting to the build (using a template filter, in this case) and only supplement this using client-side scripts, you can begin to create engaging experiences with barely any loss of performance.
Not everyone is a fan of my writing. But if you found this article at all entertaining or edifying, I do accept tips. I also have a clothing line: