Frontend build chaos

Table of contents

The straw that broke my back

I’ve come to realize the sin of Tailwind CSS:

<form class="w-132 p-2 flex items-center bg-bg-100 rounded-md focus-within:outline-2 focus-within:bg-transparent outline-focus">
<span class="icon-[mingcute--search-line] mx-2 hidden xs:inline"></span>
<input id="search" type="search" autocomplete="off" value="1 Chronicles" class="grow outline-none w-0" aria-label="Search">
</form>

I’m ashamed to say I wrote this. So I began to migrate off it. Tailwind has some JS that transforms p-2 into:

.p-2 {
padding: calc(var(--spacing) * 2);
}

Neato. I want to keep the design system of a configurable --spacing so I can dynamically try out different spacing values. But I don’t want to have to remember to write calc(var(--spacing) * 2). Enter CSS @functions:

@function --padding(--amount) {
result: calc(var(--spacing) * var(--amount));
}

Now I can write:

.my-element {
padding: --padding(2);
}

I’m a happy customer.

Happy dance

BEAUTIFUL CSS!!!!

All styling concerns are kept in my CSS file just like Tim Berners Lee envisioned all those years ago. But, sadly browser support is lacking. The standard is still a draft and only Chrome has support. No problem, I’ll just find a polyfill.

There’s two popular CSS transformers: PostCSS and LightningCSS. Surely one of them has a transformer for a nearly 2 year old proposal, right?

Super wrong.

Well, I’ll just write my own. My bundler Vite uses PostCSS by default, so I’ll fork a nice branch and iterate.

OH, but I forgot, my linter is also sad:

/* https://github.com/biomejs/biome/issues/8184 */
/* biome-ignore lint/suspicious/noUnknownAtRules: they're standard */
@function --padding(--amount) {
/* biome-ignore lint/correctness/noUnknownProperty: they're standard */
result: calc(var(--spacing) * var(--amount));
}

And if I put url statement in there my bundler won’t know how to resolve them. I guess I could use SASS and a loader…

WHY DO I NEED BUILD TOOLS?

Working with them sucks. Development builds should be production ones. Developing websites should be as simple as file://myproject.html. Web components should be the best way to write interactive components. Not React, Vue, Angular, Solid, Lit, Inferno, KO, or the newest fastest framework.

JS should be the best way to write interactive code, not Typescript or WASM. New features should be backwards compatible, not require Babel, Closure, or swc.

CSS should be the best way to write styles in a backwards-compatible manner, not SASS, LESS, PostCSS, or LightningCSS.

And I shouldn’t have to bundle ANYTHING, there should be a standard, performant module system with minimal overhead. Not:

I’m sorry, but a pre-build step is not “LIGHTNING FAST” like all of them advertise.

Linting, haha

Oh, there’s a whole separate linter ecosystem. ESLint, Biome, and quick-lint-js all have separate parsers, rulesets, and formatters. And there’s the Typescript compiler which may or may not be a “linter” to your workflow.

chaos

It’s pure chaos. Out of the box my TSX is being parsed by:

  1. ts-server for autocompletion
  2. Linter for diagnostics
  3. Bundler for imports
  4. A JS runtime for configuration like vite.config.ts
  5. Babel for compatibility and language features
  6. Occasionally tsc for typechecking

My CSS is being parsed by:

  1. css-lsp or tailwindcss or whatever LSP I’m using
  2. Linter for diagnostics
  3. Bundler for imports
  4. PostCSS for compatibility and language features

My HTML is being parsed by:

  1. LSP
  2. Linter for diagnostics
  3. Bundler for imports

Whenever a new feature is added to any of these language, 3-6 tools have to catch up for me to have a good experience using them.

RomeJS’s failed promise

Rome promised to unify developer tooling. There has been a recent (~5 year) push towards Rust build tooling, mostly spurred on by esbuild’s simplicity and success. Then they ran out of money and just shipped a linter.

New tools fracture the existing Babel, Rollup, and PostCSS ecosystem. If you need ONE plugin that’s only offered by Babel, Rollup, or PostCSS, well, you have to shell out to slow old JS and use it. It defeats the purpose of your new “LIGHTNING FAST” build tool.

And of course each comes with its own AST, utils, config file, and plugin interface.

Paths to unification

I want a single unified LSP and build tool with ONE config file that specifies targets environments, entrypoints, and configuration for EVERYTHING.

1. The old JS way is better

JS can always be extended with JS. Existing tools are mostly there. Refactor what already exists to be unified.

Since I don’t want a forever project (and to maintain it), I choose this option.

2. Rewrite in Rust | Zig | C++ | Go

JS parsers are sloooow and use lots of memory. Typescript’s compiler also sucks, so replace it or do away with it. Typescript is being rewritten in Go and oxc has core tools for Rust. Start from scratch and pick some reasonable backwards compatibility cutoff.