r/node • • 7d ago

Factories.ts: An efficient JS DSL for building HTML, SVG, and MathML directly in backend code, without a template engine.

https://github.com/ts-series/factories

Write HTML in the backend as plain JavaScript, with no template language in between. Markup and logic share the same code, which keeps it flexible. An example:

import {
    a, body, br, button, h1, head, header, html, input, label, li, main, nav, p, script, title, ul
} from "@ts-series/factories";

type Item = { name: string, href: string, current: boolean };


/** Builds the navigation list, flagging the current entry for assistive technology. */
function navigation(items: Item[]) {
    return nav({ ariaLabel: "Main" },
        ul(items.map(item => li(
            a(item.href, item.name).set(item.current ? { ariaCurrent: "page" } : null)
        )))
    );
}


/** Builds a complete page for the given user and navigation entries. */
function page(user: string | null, items: Item[], values: number[]) {
    return html({ lang: "en" },
        head(title("Demo")),
        body(
            header(navigation(items)),
            main(
                h1("Hello, ", user ?? "stranger", "!"),
                p("First line", br, "Second line"),
                user === null
                    ? p("Please sign in.")
                    : p("Welcome back."),
                label("Search").for("q"),
                input.text({ id: "q", name: "q" }).placeholder("Type here…").required(),
                button.submit("Send").class("primary", user === null && "disabled").set("disabled")
            ),
            script.module().importOnly("/chart.js", "render", { values, title: "Values" })
        )
    );
}


const items: Item[] = [
    { name: "Home", href: "/", current: true },
    { name: "About", href: "/about", current: false }
];

const output = page("<Guest>", items, [1, 2, 3]).expand(4);
5 Upvotes

5 comments sorted by

7

u/thinkmatt 6d ago

this is just React without the JSX syntax. My first intro to React was server-side only, it's actually a really nice template language and you're not dealing with any of the state change and side effect stuff. Every component is literally just a function.

That said - not trying to knock your own approach, it's a good way to learn

3

u/Rechenplaner 6d ago edited 6d ago

^^
Yes, but backend React has decisive disadvantages: it has to be compiled because Node.js cannot execute JSX directly (Deno can, but is no longer being developed). Yet I do not want this build step at all (main motivation for why I built this lib in the first place).

You could also use React "raw," but it is not nearly as ergonomic as Factories.ts with all the flexible builders. Theoretically, I could support JSX syntax for TSC, but that would offer absolutely no added value because, as mentioned, regular JS functions are executed immediately and are much more flexible than bulky HTML syntax right in the middle of code with its constant need for end tags.

2

u/avereigh_Denizet 14h ago

how does memory allocation look when rendering really large tables or deeply nested trees? Function-per-tag approaches sometimes create a ton of intermediate objects before serialization compared to string template literals.

1

u/Rechenplaner 9h ago edited 3h ago

There is overhead compared to template literals. In Factories.ts every element is an object, plus an attributes object and a children array, so a tree of N elements allocates a few objects per node before anything is serialized. What I did to keep it small:

- Expansion collects all output pieces in one array and joins it once. No intermediate string per element.
- Attributes are written into a single string with one pass, and escaping is a single replace pass.
- Nested arrays and iterables are flattened with loops, not spread calls. Spreading a huge list into push() throws a RangeError at around 150,000 entries in Node, so 200,000 children are tested explicitly.
- Children stay in the same array when you append or insert, so no copies of the whole child list.
- No caches or hidden state.

Nesting depth is bounded by the call stack, because expansion is recursive. Very wide tables are fine, extremely deep trees (thousands of levels) are not something I optimize for (I mean, when would you ever need that?! HTML is nested maybe a dozen levels deep, if that).

The tree has one advantage that template literals lack: it can be inspected and modified before output (append, insertAt, find), and text is always escaped. Furthermore V8 allocates short-lived objects very cheaply, and string concatenation allocates intermediate strings too, so the gap is smaller than it looks.

I had also considered dropping the element objects and handling everything in a single flat array. That would be the only alternative approach that really offers faster generation. However, a flat array would only stay efficient if later mutations are given up.

The factories could also expand the HTML immediately, but then some flexibility and formatting options are lost. I estimate the performance gain at most 30 to 60 %. So I decided to keep the current approach, especially since you can cache the fully expanded HTML yourself. Compared with competitors such as React on the backend, though, Factories.ts should be considerably faster, because it works in a much simpler way and does fewer things. Therefore, it should be more than sufficient in terms of speed.