JT DIGITAL fluent-html 8.1.0

TypeScript goes in,
HTML comes out.

fluent-html is a TypeScript HTML builder. It is the rendering layer under every application we ship at JT Digital. An element is a function call, its children are arguments, and attributes are chained methods. There is no template language, no virtual DOM and no build step. Interaction runs through HTMX, typed the same way.

Once a page is ordinary TypeScript, the compiler checks the agreements between files. Two files that disagree about a name stop compiling.

01 / 08 · SPECIMEN

01 WRITTEN, IN TYPESCRIPT

Li(
  Span("nightly-build").font("medium"),
  Span("passed")
    .bg("green-100").text("green-800")
    .px("2").py("0.5")
    .rounded("full").text("xs"),
).flex().items("center").gap("3")

02 PRINTED, BYTE FOR BYTE

<li class="flex items-center gap-3"><span class="font-medium">nightly-build</span>
<span class="bg-green-100 text-green-800 px-2 py-0.5 rounded-full text-xs">passed</span></li>

03 THAT STRING, ON THIS PAGE

  • nightly-build passed
The middle panel is the exact string the library returned for the code on the left.
  • VERSION 8.1.0, on npm
  • RUNTIME DEPENDENCIES zero
  • SENT TO THE BROWSER nothing, by default
02 / 08 · runs.view.ts fluent-html
type Run =  | { status: "queued";  name: string }  | { status: "running"; name: string; since: string }  | { status: "passed";  name: string; ms: number }  | { status: "failed";  name: string; ms: number; failures: number }  | { status: "skipped"; name: string; reason: string };
function Detail(run: Run) {
  return Match(run, "status", {    queued:  ()  => Span("waiting"),    running: (r) => Span(`since ${r.since}`),    passed:  (r) => Span(`${r.ms} ms`),    failed:  (r) => Span(`${r.failures} failed in ${r.ms} ms`),    skipped: (r) => Span(r.reason),  }).text("sm").text("gray-500");
}

function RunRow(run: Run) {
  return Li(Span(run.name).font("medium"), Detail(run))
    .flex().items("center").gap("3").py("2");
}
SELECTING A SHAPE NARROWS THE TYPE

02 / 08  ·  THE BENCH

Type narrowing, one branch
at a time.

A run is one of five shapes. Inside each branch the compiler already knows which shape arrived.

COLOUR, IN EVERY SPECIMEN

Blue
a name declared once, read elsewhere
Grey
every other token
Red
a refusal

NARROWED TO

{ status: "queued"; name: string }
  • status
  • name
THIS BRANCH, RENDERED HERE
  • bench waiting

PROBLEMS · 1

error TS2339: Property 'since' does not exist on type '{ status: "queued"; name: string; }'.

NARROWED TO

{ status: "running"; name: string; since: string }
  • status
  • name
  • since
THIS BRANCH, RENDERED HERE
  • browser-matrix since 14:02

PROBLEMS · 1

error TS2339: Property 'ms' does not exist on type '{ status: "running"; name: string; since: string; }'.

NARROWED TO

{ status: "passed"; name: string; ms: number }
  • status
  • name
  • ms
THIS BRANCH, RENDERED HERE
  • nightly-build 1680 ms

PROBLEMS · 1

error TS2339: Property 'failures' does not exist on type '{ status: "passed"; name: string; ms: number; }'.

NARROWED TO

{ status: "failed"; name: string; ms: number; failures: number }
  • status
  • name
  • ms
  • failures
THIS BRANCH, RENDERED HERE
  • escape-fuzz 2 failed in 240 ms

PROBLEMS · 1

error TS2339: Property 'reason' does not exist on type '{ status: "failed"; name: string; ms: number; failures: number; }'.

NARROWED TO

{ status: "skipped"; name: string; reason: string }
  • status
  • name
  • reason
THIS BRANCH, RENDERED HERE
  • wasm-pack no runner

PROBLEMS · 1

error TS2339: Property 'ms' does not exist on type '{ status: "skipped"; name: string; reason: string; }'.

Each branch above was asked for a field that belongs to a different shape.

run.name
Readable outside every branch. All five shapes carry it.
.text("sm") after Match
The chain continues past the branch table. Control flow reports what its branches built.
03 / 08 · probe.ts fluent-html
   export const userRoutes = defineRoutes("/users", {
     list:   { path: "/" },
     create: { method: "post", path: "/" },
     detail: { path: "/:id",
               params: { id: "number" } as const },
   } as const);

   userRoutes.detail({ id: 7 });
userRoutes.detail({});
userRoutes.detail({ id: "42" });

PROBLEMS · 3

03 / 08  ·  ACT I defineRoutes

Every route, written once in TypeScript.

A route is declared once: its method, its parameters, their types. If a parameter is typed as a number, the router enforces that too. A bad value never reaches a handler.

A link built without its varying part.

probe.ts(14,19): error TS2345: Argument of type '{}' is not assignable to parameter of type 'ResolveAllParamTypes<"/users/:id", { readonly id: "number"; }>'.
  Property 'id' is missing in type '{}' but required in type 'ResolveParamTypes<"/users/:id", { readonly id: "number"; }>'.

A number supplied as text.

probe.ts(15,21): error TS2322: Type 'string' is not assignable to type 'number'.

The same part read back as text.

Type 'number' is not assignable to type 'string'

ASSERTED FRAGMENT

A request only accepts a route object. No raw strings.

A ROUTE IS NOT A STRING · probe.ts fluent-html

SELECTING A LINE SHOWS WHETHER IT COMPILED

WHAT THE REQUEST IS POINTED AT
Div().hxGet("/users/7");

PROBLEMS · 1

error TS2345: Argument of type '"/users/7"' is not assignable to parameter of type 'ResolvedRoute | ExternalHref'.
Div().hxGet(userRoutes.detail.resolve({ id: 7 }));

PROBLEMS · 0

Div().hxGet(userRoutes.detail.resolve({ id: 7 }) + "?tab=logs");

PROBLEMS · 1

error TS2345: Argument of type 'string' is not assignable to parameter of type 'ResolvedRoute | ExternalHref'.
THE TYPE UNDERNEATH · routes.ts fluent-html
export type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? ParamName<Param> | ExtractParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? ParamName<Param>
      : never;

type ParamName<S extends string> =
  S extends `${infer Head}.${string}` ? ParamName<Head>
  : S extends `${infer Head}-${string}` ? ParamName<Head>
  : S;

Read at compile time, one segment at a time. The name stops at the dot, so /export/:id.csv yields id .

04 / 08 · ids.ts fluent-html
export interface Id<N extends string = string> {
  /** The raw ID string (e.g., "user-list") */
  readonly id: N;
  /** The CSS selector (e.g., "#user-list") */
  readonly selector: `#${N}`;
}

/** A view whose outermost element carries id="N". */
export type Rooted<N extends string> = { readonly [__rootIdBrand]: N };
export type RootedView<N extends string> = Rooted<N> & View;

// core/tag.ts
setId<const N extends string>(id: Id<N>): this & Rooted<N>;
setId(id?: string): this;

PROBLEMS · 3 · A REGION NAME IS A TYPE

04 / 08  ·  ACT II defineIds

One declaration names every HTMX swap target.

Every HTMX swap target is named once, as a typed object instead of a string.

A view stamped with one of those names carries it in its type. A reply built for one region cannot be sent to another.

A view with no region name on it.

Property '[__rootIdBrand]' is missing

ASSERTED FRAGMENT

A reply built for one region, sent to another.

'"probe9-other"' is not assignable to type '"probe9-panel"'

ASSERTED FRAGMENT

A component annotated : View , which widens the name away.

Argument of type 'View' is not assignable to parameter of type 'Rooted<"user-count"> & View'.
  Type 'string' is not assignable to type 'Rooted<"user-count"> & View'.

View is a union, so TypeScript names its first failing member. RootedView<"user-count"> moves the error into the component.

THE TYPE UNDERNEATH · htmx.ts · SOFT WRAPPED fluent-html
type SwapScrollValue = 'scroll:top' | 'scroll:bottom' | 'scroll:window:top' | 'scroll:window:bottom';
type SwapShowValue   = 'show:top' | 'show:bottom' | 'show:window:top' | 'show:window:bottom' | 'show:none';
type SwapTimingValue = `swap:${DelayValue}` | `settle:${DelayValue}`;

type SwapWithModifier     = `${HxSwapStyle} ${SwapModifier}`;
type SwapWithTwoModifiers = `${HxSwapStyle} ${SwapScrollValue | SwapShowValue} ${SwapTimingValue | SwapTransition}`;

export type HxSwap = HxSwapStyle | SwapWithModifier | SwapWithTwoModifiers;

One base word, then optional modifiers. The type names what it covers and rejects the rest.

THE SAME GRAMMAR, ASSEMBLED · probe.ts fluent-html

SELECTING THE PARTS ASSEMBLES THE STRING BELOW

BASE WORD
FIRST MODIFIER
SECOND MODIFIER

ASSEMBLED SOFT WRAPPED

Div().setHtmx(runRoutes.list({ target: ids.panel,
  swap: "outerHTMLouterMorphinnerHTM scroll:top scroll:middle swap:500ms swap:500ms scroll:top" }));

PROBLEMS · 0

PROBLEMS · 0

PROBLEMS · 0

PROBLEMS · 0

PROBLEMS · 0

PROBLEMS · 1

error TS2322: Type '"outerHTML scroll:top scroll:top"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2820: Type '"outerHTML scroll:middle"' is not assignable to type 'HxSwap | undefined'. Did you mean '"outerHTML scroll:top"'?

PROBLEMS · 1

error TS2322: Type '"outerHTML scroll:middle swap:500ms"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2820: Type '"outerHTML scroll:middle scroll:top"' is not assignable to type 'HxSwap | undefined'. Did you mean '"outerHTML scroll:window:top"'?

PROBLEMS · 0

PROBLEMS · 1

error TS2322: Type '"outerHTML swap:500ms swap:500ms"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2322: Type '"outerHTML swap:500ms scroll:top"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 0

PROBLEMS · 0

PROBLEMS · 0

PROBLEMS · 0

PROBLEMS · 0

PROBLEMS · 1

error TS2322: Type '"outerMorph scroll:top scroll:top"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2820: Type '"outerMorph scroll:middle"' is not assignable to type 'HxSwap | undefined'. Did you mean '"outerMorph scroll:top"'?

PROBLEMS · 1

error TS2322: Type '"outerMorph scroll:middle swap:500ms"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2820: Type '"outerMorph scroll:middle scroll:top"' is not assignable to type 'HxSwap | undefined'. Did you mean '"outerMorph scroll:window:top"'?

PROBLEMS · 0

PROBLEMS · 1

error TS2322: Type '"outerMorph swap:500ms swap:500ms"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2322: Type '"outerMorph swap:500ms scroll:top"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2820: Type '"innerHTM"' is not assignable to type 'HxSwap | undefined'. Did you mean '"innerHTML"'?

PROBLEMS · 1

error TS2322: Type '"innerHTM swap:500ms"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2820: Type '"innerHTM scroll:top"' is not assignable to type 'HxSwap | undefined'. Did you mean '"innerHTML scroll:top"'?

PROBLEMS · 1

error TS2820: Type '"innerHTM scroll:top"' is not assignable to type 'HxSwap | undefined'. Did you mean '"innerHTML scroll:top"'?

PROBLEMS · 1

error TS2820: Type '"innerHTM scroll:top swap:500ms"' is not assignable to type 'HxSwap | undefined'. Did you mean '"innerHTML scroll:top"'?

PROBLEMS · 1

error TS2820: Type '"innerHTM scroll:top scroll:top"' is not assignable to type 'HxSwap | undefined'. Did you mean '"innerHTML scroll:top"'?

PROBLEMS · 1

error TS2322: Type '"innerHTM scroll:middle"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2322: Type '"innerHTM scroll:middle swap:500ms"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2322: Type '"innerHTM scroll:middle scroll:top"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2322: Type '"innerHTM swap:500ms"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2322: Type '"innerHTM swap:500ms swap:500ms"' is not assignable to type 'HxSwap | undefined'.

PROBLEMS · 1

error TS2820: Type '"innerHTM swap:500ms scroll:top"' is not assignable to type 'HxSwap | undefined'. Did you mean '"innerHTML scroll:top"'?

ALL 36 COMBINATIONS COMPILED, ANSWERS UNEDITED

05 / 08 · projects.controller.ts application template
export default defineController(projectRoutes, {
  list:   page(ProjectList),  detail: page(ProjectDetail, { sitemap: listIds }),  create: {
    guards: [authGuard],
    POST: async (request, reply) => {      const project = await createProject(request.body);
      return reply.renderView(ProjectDetail(project));    },
  },
  panel:  fragment(ProjectPanel),});

THE CONTROLLER AS IT SHOULD BE WRITTEN

BREAKING IT, ONE WAY AT A TIME

05 / 08  ·  ACT III defineController

A controller that misses a route does not compile.

TWO LAYERS, BOTH OURS

defineRoutes and defineIds ship in the fluent-html package. defineController , the request verbs and the styling presets are the application template we build on it. Every project starts with both, and the badges on this page say which is speaking.

Each route needs one entry in the controller, keyed by its HTTP method. Six ways to get that wrong, and what the compiler says to each.

THE EDIT, AND WHAT IT COST · projects.controller.ts application template
// the panel entry, deleted

PROBLEMS · 1

Property 'panel' is missing in type '{ list: …; detail: …; create: …; }' but required in type '{ list: …; detail: …; create: …; panel: …; }'.
THE EDIT, AND WHAT IT COST · projects.controller.ts application template
  settings: page(SettingsPage),

PROBLEMS · 1

Type '(props: …) => PageAnswer' is not assignable to type "Error: 'settings' is not a route in this registry".
THE EDIT, AND WHAT IT COST · projects.controller.ts application template
    GET: async (request, reply) => {

PROBLEMS · 1

Object literal may only specify known properties, and 'GET' does not exist in type 'ControllerEntry<{ readonly method: "post"; readonly path: "/"; }, unknown>'.
THE EDIT, AND WHAT IT COST · projects.controller.ts application template
  list:   page(ProjectList),  // route def declares no sitemap

PROBLEMS · 1

Type '(props: …) => PageAnswer' is not assignable to type "Error: public GET needs a sitemap stance in its route def or a guard on its entry".
THE EDIT, AND WHAT IT COST · projects.controller.ts application template
  detail: fragment(ProjectPanel),

PROBLEMS · 1

Type 'FragmentResult<"projects-panel">' is not assignable to type 'PageAnswer'.
THE EDIT, AND WHAT IT COST · projects.controller.ts application template
      reply.renderView(ProjectDetail(project));  // no return

PROBLEMS · 1

Type 'void' is not assignable to type 'PageAnswer'.

Every error above is real compiler output, quoted whole.

HOW IT IS DONE · define-controller.ts application template
export function defineController<
  R extends AnyRouteRegistry,
  T extends Record<keyof R, unknown>,
>(
  routes: R,
  impl: {
    [K in keyof T]: K extends keyof R
      ? ControllerEntry<R[K], T[K]>
      : `Error: '${K & string}' is not a route in this registry`;
  },
): FastifyPluginAsync {

One mapped type over the route registry. A key that is not a route resolves to a string, and a string is not a handler, so the message the reader sees is the type itself.

THE TYPE UNDERNEATH · swap-verbs.ts · SOFT WRAPPED application template
type DeclaresNothing = {
  readonly ["this route's def declares no `render` stance, so a fragment verb needs `render: ids.…` on it (silence means the route answers a page); an ad-hoc `hx()` url has no def, so it goes through `.setHtmx()`"]: never;
};

A rule the compiler cannot explain becomes the property it must name.

06 / 08  ·  OUTPUT

The HTML it prints, and what cannot get into it.

The library renders to a string. What it emits can be read directly. A branch that does not run produces no markup at all.

row.view.ts fluent-html
function Row(run: Run) {
  return Li(
    Span(run.name).font("medium"),
    IfThen(run.ms > 0, () => Span(`${run.ms} ms`).text("gray-500")),
  ).flex().gap("3");
}

ms = 1680

<li class="flex gap-3"><span class="font-medium">nightly-build</span>
<span class="text-gray-500">1680 ms</span></li>

ms = 0

<li class="flex gap-3"><span class="font-medium">nightly-build</span>
</li>

CLOSED VALUES

Every value a styling method accepts comes from a fixed list.

EVERY VALUE IS ON A LIST · SOFT WRAPPED fluent-html

SELECTING A CALL SHOWS THE COMPILER'S ANSWER

WRITTEN

PROBLEMS · 0

PROBLEMS · 1

error TS2345: Argument of type '"4.5"' is not assignable to parameter of type 'TailwindSpacing'.

PROBLEMS · 0

PROBLEMS · 1

error TS2345: Argument of type '"centre"' is not assignable to parameter of type '`[${string}]` | "left" | "right" | "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "7xl" | "8xl" | "9xl" | "inherit" | "current" | "transparent" | "black" | ... 495 more ... | "pretty"'.

PROBLEMS · 1

error TS2820: Type '"blue-60"' is not assignable to type 'TailwindColor | undefined'. Did you mean '"blue-600"'?

PRESETS ARE FUNCTIONS

The main abstraction is higher-order functions: functions that take or return other functions. .apply() takes any number of styling functions. ForEach takes the function that renders one item.

A FUNCTION AS AN ARGUMENT · tag.ts, iteration.ts fluent-html
apply(...fns: ((tag: this) => unknown)[]): this;

export function ForEach<T, R extends View>(
  views: Iterable<T>,
  renderItem: (item: T, index: number) => R
): R[];
A FUNCTION AS A RESULT · stylers.ts application template
export type Styler = <T extends Tag>(t: T) => T;
export type StylerFor<T> = (value: T) => Styler;

A preset chosen by a value returns another function.

ADDRESSES AND TEXT

Values are escaped on the way out. An attribute carrying an address has its scheme checked before it is written.

seven inputs, and what the renderer wrote fluent-html
Seven inputs written with the library, and the markup it produced for each.
WRITTEN RENDERED
Div(name), name is Ana <script>steal()</script> Novak <div>Ana &lt;script&gt;steal()&lt;/script&gt; Novak</div>
Div(Raw(name)), the opt-out <div>Ana <script>steal()</script> Novak</div>
A("Click").setHref("javascript:steal()") <a href="about:blank">Click</a>
the same, with a tab inside the scheme, java⇥script: <a href="about:blank">Click</a>
A("Click").setHref("/orders/12") <a href="/orders/12">Click</a>
Img().setSrc("data:image/svg+xml;base64,…") <img src="about:blank">
Img().setSrc("data:image/png;base64,…") <img src="data:image/png;base64,iVBORw0=">

The tab in the fourth row is normalised away before the scheme is read. An SVG is refused because it can carry a script.

07 / 08  ·  FIRST RESPONSE

Server-side rendering, complete in the first response.

A server-rendered page arrives complete. Nothing has to execute for the content to exist. This matters for search, because content that appears only after JavaScript runs is invisible wherever JavaScript does not run.

Client-side rendering solves a different problem. A single-page application keeps a virtual DOM in the browser, which gives you interaction without a round trip. But it splits the program across a boundary no compiler checks. State lives in two places, and the copies can drift.

fluent-html stays on the server. An interaction names a route and a swap target, the server answers with a fragment, and the page stays one program with one copy of its state. Both sides are TypeScript, so the compiler reads them together. A request aimed at a region no route serves, or a reply built for the wrong region, stops the build.

  • EVERY INTERACTION a round trip
  • FEEDBACK FASTER THAN THE NETWORK cannot work this way
  • OFFLINE out
  • GENUINELY NEEDS CLIENT CODE canvas, editors, drag and drop, charts, media

A DRAWER OPENING, A COPY BUTTON, A DISMISSAL · THE SAME FILE ON EVERY PAGE

one shared file, 5.84 KB minified, 2.63 KB gzipped

THE SITE MAP · APPLICATION TEMPLATE

A public page that never registered with the site map does not compile. Most indexing failures are not subtle. They are a page nobody listed.

A PAGE NOBODY LISTED · define-controller.ts · SOFT WRAPPED application template

PROBLEMS · 1

Error: public GET needs a sitemap stance in its route def or a guard on its entry

This page will be a public GET at /modules/fluent-html , so it needs its own stance to compile.

08 / 08  ·  REACH

Measured across 53 TypeScript codebases.

Our own repositories at JT Digital, counted on 15 August 2026. Not outside adopters.

census fluent-html
CODEBASES
53
FILES
12,332
CALLS
249,303
TOP 50 METHODS COVER
92.1%

THE ENGINE

2,162 tests in 267 suites, passing in 1.68 seconds. Two render loops exist: an eager one for strings and a generator for streaming. A test asserts both produce the identical string.

SPEED, SCOPED

One benchmark on pages dense with request attributes measured 36.51 thousand renders per second against 18.1 thousand. A realistic page gains 25 to 45%. Content heavy with escaped text is about 6% slower.

  • SECOND WALL 32 lint rules
  • BROWSER SUITE, STRICT CSP 68 checks
  • COVERAGE GATE, ON THREE VERSIONS OF NODE 95% of lines, 90% of branches