Skip to content

On a server ​

The renderer returns one string, synchronously. There is nothing to await and no framework integration to install — set the content type and send it.

ts
import { Hono } from 'hono';
import { frame } from '@itsy/html/frame';

const app = new Hono();

app.get('/orders', (c) => {
  const view = frame({ lang: 'en', title: 'Orders', content: Orders(data) });
  return c.html(view.markup);
});
ts
import Fastify from 'fastify';
import { frame } from '@itsy/html/frame';

const app = Fastify();

app.get('/orders', (_req, reply) => {
  const view = frame({ lang: 'en', title: 'Orders', content: Orders(data) });
  reply.type('text/html; charset=utf-8').send(view.markup);
});
ts
import { createServer } from 'node:http';
import { frame } from '@itsy/html/frame';

createServer((_req, res) => {
  const view = frame({ lang: 'en', title: 'Orders', content: Orders(data) });
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
  res.end(view.markup);
}).listen(3000);

Send view.markup

Html is an object. Express, Fastify and Koa send an object as JSON, and their types accept it, so nothing flags it before a request does. What each one does.

The page is one string ​

There is no streaming and no partial flush. frame() builds the whole document and hands it over. For most pages that is the simpler trade: no suspense boundaries, no out-of-order chunks, and the Content-Length is known.

To send a shell before the data is ready, send two responses — a fast page and a fetch — rather than trying to split a template.

A nonce per request ​

frame({ nonce }) adds the nonce to every script and style entry that has none of its own.

ts
app.get('/orders', (c) => {
  const nonce = crypto.randomUUID();
  c.header('content-security-policy', `script-src 'nonce-${nonce}'; object-src 'none'`);
  return c.html(frame({ lang: 'en', title: 'Orders', content, scripts, nonce }).markup);
});

The library never writes an inline event handler, so a policy with no unsafe-inline for scripts works as-is. See what it does not do.

Check the output in development ​

ts
import { check } from '@itsy/html/check';

if (process.env.NODE_ENV !== 'production') {
  for (const p of check(view)) console.warn(`[${'rule' in p ? p.rule : `html ${p.code}`}] ${p.message}`, p.near);
}

In the production build check() returns [], so the guard above is about skipping the loop, not about safety. Leaving the call in unguarded is fine.

MIT licensed.