Making the case for Skooma

𒎏Wii 🏳️‍⚧️ - Aug 6 '23 - - Dev Community

Introduction

Skooma.js is a small library I built for myself to solve the problem of generating dynamic HTML from vanilla JavaScript. It's an adaptation of a Lua library of the same name to JS, with some additional quality of life improvements that only work because it runs in the browser.

The point of this post is not to convince anyone to use it. The API is relatively stable and it seems relatively bug-free by now, but this is still primarily intended as my personal helper library. Instead I want to make an argument for why I think it was a good idea to build it.

History

Skooma, as mentioned above, started out as a Lua library to generate HTML on the server side. The core concept is simple: for every HTML (or XML) tag, there is a function that takes the tags contents and returns some representation of the tag. While the library allows users to mutate the returned elements (represented as a reeeeeally simple DOM structure), the library itself is free of side-effects (with the exception of certain helpers that are explicitly about side effects), so it works well with a functional approach.

You can map an array of strings with the span function to turn it into an array of span elements containing the strings as their text. Complex structures ("components") can easily be composed as new functions.

The primary motivation for this library was a general dissatisfaction with existing ways of writing HTML. Plain HTML and most templating languages are cumbersome to write. And while modern editors make the experience a lot less painful, with features like auto-closing tags, it still seems a bit backwards to need such heavy tooling to just write it.

Some of the more DLS-like templating languages like HAML get a lot closer to what I want, but I am really not a fan of having a separate DSL that is not quite its host language, not quite HTML and also not quite its own language.

A better approach, in my mind, was the one found in the lapis framework, where html is generated from Lua functions (usually moonscript compiled to Lua, which makes for cleaner code) that can be nested. I also wrote my own iteration of this concept in the form of MoonHTML, but eventually abandoned the project because emitting the HTML as a side-effect instead of returning it came with a variety of scalability problems that made it easier for smaller templates but more complex in bigger projects.

The result was eventually Skooma (named after a fictional drug in the Elder Scrolls universe, made from a substance called moon sugar), which I still use to this day whenever I have the need to generate some HTML programmatically or just don't feel like typing out the actual HTML.

Given the many similarities between Lua and JavaScript, it was only a matter of time for me to decide to port the concept from one language to another, and the fact that browsers already have a DOM API means that whole part of the library can be removed and the result is still more powerful than working with my custom mini DOM.

Features

Skooma is best explained by exmaple, as a big part of the point is the (relatively) clean-looking code that looks somewhat like a purpose-built DSL.

import {html} from '/skooma.js'

const user = ({name, email, picture}) =>
    html.div({class: 'user'},
        html.img({src: picture}),
        html.h2(name),
        html.a(email, {href: `mailto:${email}`})
    )

fetch_users_from_somewhere()
    .map(user)
    .each(document.body.append)
Enter fullscreen mode Exit fullscreen mode

In this example, I define a simple user component that takes an object with some attributes and generates a DOM structure to represent it. The HTML object is a proxy that generates functions for generating DOM nodes as writing html.div(content) is a lot nicer than html.tag("div", content). Proxy really is a vastly underrated JavaScript feature.

The outermost div tag is given three other tags as its children, and an object representing its HTML attributes. This API is very flexible; one can pass several child elements and objects in whatever order and even nest them in arrays (which can then be re-used).

Event Handlers

Since passing functions into HTML attributes makes no sense, this case is used for setting event handlers instead:

html.button("Click me!", {
   click: event => alert("Button has been clicked!")
})
Enter fullscreen mode Exit fullscreen mode

This internally uses addEventListener instead of setting an onclick attribute, so this even lets one add several handlers of the same type, albeit in separate objects.

Initialisers

Just like with attributes, putting a function in the child-list of a DOM element makes no sense, so this case is used to simply pass arbitrary initialisers to the element. These get called as soon as they're found instead of deferred, so any arguments that follow will not yet be applied. I have no strong opinion on whether deferring them would make more sense and might implement this if I ever find a good reason to prefer it.

const register = element => { my_element_registry.add(element) }
// ^ pretend this gets used somewhere else to do something useful

html.button("Click me?", register)
Enter fullscreen mode Exit fullscreen mode

Dataset

Data-attributes can instead be set by passing a special dataset object key to an element constructor:

const form_children = [/* ... */]
html.form({
    'data-id': '0001', // this is ugly
    dataset: { id: '0001' }, // this is nicer
}, form_children)

// Excessively hacky and ugly:
html.form(
    Object.fromEntries(Object.entries(user).map(
        ([key, value]) => ["data-"+key, value])
    ),
    form_children
)

// This is how things should be:
html.form({dataset: user}, form_children)
Enter fullscreen mode Exit fullscreen mode

Shadow-Root

Likewise, the key shadowRoot is also special, in that its value is added to the new object's shadow root, which is created if it doesn't exist yet. This follows the same logic as the function's arguments, so it can be a DOM node, a string, or a (possibly nested) array of child elements.

html.div({
    shadowRoot: [
        html.h2("Greetings from the shadow DOM!"),
        html.slot(),
        html.span("It's very shadowy in here...")
    ]
}, html.i("Wait, where am I?!"))
Enter fullscreen mode Exit fullscreen mode

Styling

Similar to dataset, the style attribute can be used to pass an object and have its values assigned to the DOM node's style attribute.

html.span({
    style: {
        textDecoration: 'underline',
        // gets transformed to kebab-case
    }
})
Enter fullscreen mode Exit fullscreen mode

Custom Elements

Custom elements, which have hyphens in their names, don't have to be created using square braces (although you can, if you hate yourself) html['my-component']("inner text"); instead, camelCase tag names are converted to kebab-case just like style properties and html attributes, so you can just write html.myComponent("inner text") instead.

html.typeWriter({
    customProperty: "I have a property!",
}, [
    html.span("Greetings, I am a custom element"),
])
Enter fullscreen mode Exit fullscreen mode

As a Learning Experience

All in all, this was a really fun project to implement. Proxy objects are really nice, and porting the code from Lua, which has a completely different way of doing a very similar thing, was ultimately still really easy. And even though I only used a small part of the Proxy API, I still used the chance to read up on some of the other possibilities it offers. Proxy is cool!

Comparing to Alternatives

Interpolation++

Comparing skooma.js to anything from String ${'templates'} to traditional PHP, where you still write HTML but can interpolate content into your output and sometimes even insert blocks of logic into it, skooma does as good a job as all the alternatives below at getting rid of my primary problem: HTMLs annoying syntax.

HAML & Co.

These templating "languages" honestly aren't bad. I am perfectly happy writing HAML templates whenever I'm having to work with rails, and any of its alternatives in other languages would work as well.

My main problems with these are the context-switching between languages, and the fact that it's not nearly as easy to refactor by extracting common structures into sub-components, as you can't just draw them out into a function and use it later on.

VanJS

Had I found this library before writing Skooma, I probably would have just used it instead. It does basically the same thing, albeit with less convenience features, and will most likely still use this for work projects, as it has the benefit of being a "proper" framework (i.e. I didn't write it myself and it has a fancy website), so it will simply seem more legitimate to coworkers. Gotta love workplace politics.

I will say though, that I am not at all a fan of how it encourages importing all the tag functions into the current scope. That just screams scalability nightmare, and in any bigger project this will inevitably lead to a) lots of unused tag functions still being declared and b) constant "why isn't it wor— oh I haven't imported ul yet"

But that's just a style choice and the library doesn't force you to do things that way.

Skooma (Lua)

This is, to me, the gold standard of syntax. Lua has some small advantages in its syntax that make it easier to make code look nice:

  1. Ommitting braces when calling a function with a table literal as its only argument
  2. Tables acting both as arrays and maps
  3. Semicolons being allowed instead of commas (I hate commas for multi-line things)
  4. Overriding _ENV and loading code with custom environments

So the user component from my first example could instead be written like this:

function user(u)
    return div{
        class = "user";
        img { src = u.picture };
        h2 { name };
        a {
            email;
            href: "mailto:" .. u.email
        }
    }
end
Enter fullscreen mode Exit fullscreen mode

And if I want to instead write it in moonscript or yuescript, a language (and a dialect of it) that compiles to Lua, I could even write my component like this:

user ==>
    div class: "user"
        * img src: @picture
        * h2 name
        * a email, href: "mailto:#{@email}"
Enter fullscreen mode Exit fullscreen mode

which is starting to look almost like an actual DSL, except it's not, and I can put as much "real programming logic" like loops, function calls, etc. right in my HTML code and the syntax is the exact same between logic and template.

Admittedly, I could probably achieve the same in JS if I used something like CoffeeScript.

Conclusions

  1. Writing this library was definitely worth it. It was fun, I got to practise using Proxy and the result is definitely quite usable. I would absolutely do this again and encourage anyone to try projects like this one even if just for the fun aspect alone.
  2. I already am and will continue to use skooma.js for my personal projects. Once I got used to it, it just feels weird to imagine using things like JSX or even assembling DOM nodes by hand using browser APIs.
  3. I can't really be bothered to lobby people to use my cute little project and turn it into a "real thing", but if you're interested, or maybe even use VanJS and want to see how it compares, by all means, use Skooma. It's as production-ready as a single dev can make it, and due to its simplicity, bugs happen rarely and can usually be fixed within half an hour. Documentation is available at darkwiiplayer.github.io/js/skooma.html
. . . . . . . . . . . . . . . . . . . . .