But what if we used TypeScript anyway

Written: 2025-08-06 23:15:31 · Last Updated: 2025-08-07 00:18:19

As we established before, this site doesn't use a JavaScript bundler. Even as detrimental as it may sound in practice, we don't want to depend on more than one build step for this website. This brings interesting challenges, but also some very unpleasant limitations.

Note: This limitation is purely arbitrary and we are not opposed to using a bundler. It just sounds less fun. (Which goes kinda under-appreciated in software)

Original idea

Initially, we wanted to use something like Solid or lit, since our intention was to have light-weight component rendering and not much else.

However, both are designed to be used with a bundler (or with a CDN like jsDelivr), which definitely is a regression from the jQuery days where it could've been right-clicked and downloaded for use in anything, by anyone and their dog, probably on their smart toaster.

Also, one of our goals is to serve readable unminified JavaScript code, and many bundlers definitely fail at this task.

Plan B

Because of this setback, we bit the bullet and decided to implement the entire admin panel logic in pure JavaScript. Without any dependencies, everything becomes quite verbose, especially with the document.createElement syntax.

We've refactored some parts over time, and Custom Elements allowed us to do some form heavy-lifting. Despite that, the codebase was quickly growing, and type errors were quickly piling up. JSDoc would've helped here, but that's really verbose and working on the code already felt like sunk cost.

So how about we use a TypeScript compiler anyway?

Probably the most intuitive choice for this job is SWC, which can be embedded right into the resulting binary. However, as it turns out, most native-compiled JavaScript ecosystem tools have a tendency to have zero fucking documentation regarding the use of the native API.

Unlike some other Rust web tooling (won't name and shame, it is left as an exercise for the reader), SWC at least bothers to link to its Rust API documentation, although doc comments are more of an exception than a rule.

After scrambling for roughly three hours and finding out what we need are essentially two functions (swc::Compiler::process_js and swc_ecma_parser::parse_file_as_module), we integrated it with the Axum router in our code, and after fixing some API misuse bugs, it worked almost "first try".

The good

SWC allows us to use JSX syntax, assuming we provide our own implementation of the magic React.createElement function. Having a JavaScript compiler was also a great opportunity to set up a tsconfig.json file for proper IDE language server support.

I present to you, Poor Natty's React:

export type Ref<T> = { value: null | T };

// Create new empty ref
export function refN<T>(): Ref<T> {
    return {value: null}
}

// Wraps a value in a ref
export function asRef<T>(value: T): Ref<T> {
    return {value};
}

export function createElementN<T extends keyof HTMLElementTagNameMap>(
    element: T,
    props: HTMLElementTagNameMap[T] | null,
    ...children: (Node | string)[]
) {
    const el = document.createElement(element);

    if (props != null) {
        for (const prop in props) {
            const val = props[prop];

            // Assign refs
            if (prop === "ref" && typeof val === "object" && "value" in val) {
                val.value = el;
                continue;
            }

            // Handling for JSX attributes like onClick
            const nameLower = prop.toLowerCase();
            const name = nameLower in el ? nameLower : prop;

            el[name] = val;
        }
    }

    el.replaceChildren(...children);
    return el;
}

There's no reactivity (yet), and it's jank, but it works and greatly reduces the amount of work to get something done.

In WebStorm, the IDE might complain about the createElementN being unused, but that can be fixed with a little trick from Babel, SWC's grandma.

/** @jsx createElementN */
import {createElementN} from "./dom.tsx";

The bad

Unfortunately, all this magic comes with a hefty cost of including a massive project in our code, if not bigger than every other dependency combined. Adding SWC to our dependencies also doubled our (already abysmal) compile time, which isn't great in Rust to begin with.

The good news is, we rarely have to rebuild the web server, since most parts we want to change are purely runtime tasks. One exception is the HTML templates for askama, which is a rather "automagic" Rust library for compiling templates into Rust via proc-macros, which forces a rebuild any time a file is changed. The link time adds up.

The unsolved

First of all, we were lazy and didn't implement serving sourcemaps yet, so debugging the JavaScript code isn't as good as it could be. We also haven't added support for custom elements in JSX yet.

SWC is also not really a bundler and will not solve our "how do we embed CodeMirror" issue. We really don't want to embed Rolldown nor RsPack, as they don't really solve our issue either. Both would still require bringing a dependency installation step with npm.

JSPM seemed like an interesting premise, however we were quickly turned away by the website not working properly on mobile, and the tool having an odd affinity for Claude, which definitely doesn't spark "good software" joy.

Still a lot to figure out, I don't know if we will have Monaco or CodeMirror for writing articles any time soon. Might revisit the idea of import maps, who knows. One thing is for sure, we are not committing node_modules to the Git repository just for one funny dependency.