Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .agents/plans/2026-10-19-issue14-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Issue #14 slice: server-side component rendering (pages, SSG, ISR)

Slice of P5/M3 "component framework server side". Not the whole framework.

## Lands
- `@chttp/html` subset exports: `page(head, body)` full-document shell (`<!doctype html>` + html/head/body) and `IsrPage` — TTL-cached markup (`render` closure + `revalidateSeconds`; re-renders lazily on expiry via `Date.now()`).
- SSG pattern: render page markup once at startup, serve from a route — no new API needed; demonstrated by the example.
- `examples/pages`: tiny blog. `/` index rendered once (SSG); `/post/:slug` ISR (revalidate 5s); components as `(props) => string` fns. node:test e2e over real HTTP.
- Unit tests `packages/html/test/page.test.js`; gate `bench/apps/chttp-html.ts` exercises new exports (100% static required).
- Docs: `docs/html.md` gains a "Pages, SSG and ISR" section + honest limits; package README updated.

## Remaining surface (PR body, not this PR)
- Per-request data loading / load fns; layout nesting; streaming SSR; islands/hydration; build-time prerender + `@chttp/static` handoff; SWR background revalidate; HEAD/metadata model beyond a raw head string; JSX->h lowering in unplugin.

## Gates
`scriptc coverage bench/apps/chttp-html.ts` 100%; `pnpm --filter @chttp/html test`; `pnpm --filter chttp-example-pages test`; oxlint/oxfmt.
5 changes: 5 additions & 0 deletions .changeset/pages-ssr-slice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chttp/html": minor
---

Add `page(head, body)` full-document shell and `IsrPage` revalidate cache: the pages/SSG/ISR slice of issue #14. SSG is render-once-at-startup; ISR re-renders lazily after the TTL (blocking semantics, no background refresh). See `examples/pages`.
13 changes: 11 additions & 2 deletions bench/apps/chttp-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { createRouter } from "../../packages/router/src/index.ts";
import {
BARE,
Fragment,
IsrPage,
createElement,
escapeHtml,
h,
html,
isValidAttrName,
isValidTag,
isVoidElement,
page,
raw,
} from "../../packages/html/src/index.ts";

Expand All @@ -23,15 +25,22 @@ const app = createRouter();
app.get("/page", (c) => {
const q = c.req.query("q");
const name = q === null ? "world" : q;
const page = Fragment([
const pageMarkup = Fragment([
h("meta", ["charset", "utf-8"], []),
Card("hi " + name, html`<p>${name}</p>`),
createElement("input", ["disabled", BARE, "value", name]),
raw`<hr/>`,
h("div", ["safe", "true"], ["<b>" + name + "</b>"]),
]);
const ok = isValidTag("div") && isValidAttrName("data-x") && isVoidElement("br");
return c.html(ok ? page : "");
return c.html(ok ? pageMarkup : "");
});

const index = new IsrPage(
() => page(h("title", [], ["home"]), Card("isr", html`<p>cached</p>`)),
30,
);

app.get("/isr", (c) => c.html(index.html() + index.freshUntil()));

app.serve({ port: Number(process.argv[2] || 3000) });
51 changes: 41 additions & 10 deletions docs/html.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@

## API

| Export | Signature | Notes |
| --- | --- | --- |
| `escapeHtml` | `(s: string) => string` | Escapes `& < " '` to `&amp; &lt; &#34; &#39;`, same bytes as kitajs. Charcode loop, no regex |
| `h` | `(tag: string, attrs: string[], kids: string[]) => string` | `attrs` is a flat `[name, value, ...]` array. Void elements self close |
| `createElement` | `(name: string, attrs: string[], ...children: string[]) => string` | kitajs shaped alias of `h` |
| `Fragment` | `(kids: string[]) => string` | Siblings without a wrapper |
| `html` | tagged template | Escapes interpolations, static text is trusted markup |
| `raw` | tagged template | No escaping, for composing already rendered markup |
| `BARE` | `string` | Attribute value sentinel for bare boolean attributes |
| `isVoidElement`, `isValidTag`, `isValidAttrName` | `(s: string) => boolean` | Exposed helpers |
| Export | Signature | Notes |
| ------------------------------------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| `escapeHtml` | `(s: string) => string` | Escapes `& < " '` to `&amp; &lt; &#34; &#39;`, same bytes as kitajs. Charcode loop, no regex |
| `h` | `(tag: string, attrs: string[], kids: string[]) => string` | `attrs` is a flat `[name, value, ...]` array. Void elements self close |
| `createElement` | `(name: string, attrs: string[], ...children: string[]) => string` | kitajs shaped alias of `h` |
| `Fragment` | `(kids: string[]) => string` | Siblings without a wrapper |
| `html` | tagged template | Escapes interpolations, static text is trusted markup |
| `raw` | tagged template | No escaping, for composing already rendered markup |
| `BARE` | `string` | Attribute value sentinel for bare boolean attributes |
| `isVoidElement`, `isValidTag`, `isValidAttrName` | `(s: string) => boolean` | Exposed helpers |

```ts
import { BARE, escapeHtml, h, html } from "@chttp/html";
Expand Down Expand Up @@ -58,6 +58,37 @@ Tag and attribute names are validated and throw, so they cannot be used for inje

`@chttp/html/jsx-runtime` (and `jsx-dev-runtime`) export `jsx`, `jsxs`, `Fragment` and a `JSX` namespace so `"jsx": "react-jsx"` with `"jsxImportSource": "@chttp/html"` works under Node. Output is produced by `h()`, so it is byte identical to the same tree written with `h`. JSX props map to attrs (`className` to `class`, `true` to a bare attribute, `false`, `null` and `undefined` skipped, `safe` honoured). This runtime is not in the subset and must never be imported by code that compiles to the ELF. Production builds will lower `.tsx` to plain `h`/`html` calls at build time (planned unplugin `jsx` slice).

## Pages, SSG and ISR

`@chttp/html` also ships the page-level surface of issue #14's slice: a document
shell and a revalidate cache. Components stay plain `(props) => string`
functions; there is no component class, virtual DOM, or client runtime.

| Export | Signature | Notes |
| -------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `page` | `(head: string, body: string) => string` | Full document: `<!doctype html>` + `<html><head>` + `<body>`. Args are markup, not escaped |
| `IsrPage` | `new IsrPage(render: () => string, revalidateSeconds: number)` | Caches `render()` output; re-renders on the first `html()` read after the window elapses |
| `IsrPage.html` | `() => string` | The cached document |
| `IsrPage.freshUntil` | `() => number` | Epoch ms of the next re-render instant |

**SSG** needs no API: call your component once at startup and serve the baked
string from a route. **ISR** wraps a render function in `IsrPage` — revalidation
is synchronous (Next.js blocking-fallback semantics); the reader that hits an
expired window pays the render. There is no background refresh inside the
subset. See `examples/pages` for a working app.

```ts
const index = Layout("posts", PostList(POSTS)); // once — SSG
app.get("/", (c) => c.html(index));

const post = new IsrPage(() => Layout("post", PostCard(loadPost())), 60); // ISR
app.get("/post", (c) => c.html(post.html()));
```

This is a slice, not a framework: no per-request data loading, nested layouts,
streaming, islands/hydration, or build-time prerender yet — see issue #14 for
the remaining surface.

## Compile gate

`scriptc coverage bench/apps/chttp-html.ts` must report 100% static. `pnpm --filter @chttp/html test` runs the unit tests, the XSS payload cases, and the jsx-runtime equivalence test.
16 changes: 16 additions & 0 deletions examples/pages/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# pages — SSG + ISR slice of issue #14

Server-side component rendering on `@chttp/router` with `@chttp/html`. Components
are plain `(props) => string` functions; `page(head, body)` wraps them in a full
document.

- `GET /` — index rendered once at startup and served as a baked string (SSG).
- `GET /post/:slug` — one `IsrPage` per post: cached markup re-rendered on the
first read after the revalidate window (ISR, blocking semantics).
- `GET /post/nope` — 404 document.

```sh
pnpm start:node # node --experimental-strip-types
pnpm build && pnpm start # native ELF via scriptc
pnpm test
```
19 changes: 19 additions & 0 deletions examples/pages/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "chttp-example-pages",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "scriptc build src/main.ts -o app",
"start": "./app",
"start:node": "node --experimental-strip-types src/main.ts",
"test": "node --experimental-strip-types --test test/*.test.ts"
},
"dependencies": {
"@chttp/html": "workspace:*",
"@chttp/router": "workspace:*"
},
"devDependencies": {
"scriptc": ">=0.3.2"
}
}
68 changes: 68 additions & 0 deletions examples/pages/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// pages: server-side component rendering on chttp — SSG + ISR slice.
// Components are plain (props) => string functions returning markup from
// @chttp/html; the same source compiles to a native ELF via `pnpm build`.
import { IsrPage, escapeHtml, h, page } from "@chttp/html";
import { createRouter } from "@chttp/router";

interface Post {
slug: string;
title: string;
body: string;
}

const POSTS: Post[] = [
{ slug: "hello", title: "Hello, pages", body: "Server-rendered once at startup." },
{ slug: "isr", title: "Incremental static", body: "Re-rendered after a TTL." },
];

// Layout wraps page(head, body): a full document, not a fragment.
function Layout(title: string, body: string): string {
return page(
h("meta", ["charset", "utf-8"], []) + h("title", [], [escapeHtml(title)]),
h("main", [], [body]),
);
}

function PostCard(post: Post): string {
return h(
"article",
["class", "post"],
[h("h1", [], [escapeHtml(post.title)]), h("p", [], [escapeHtml(post.body)])],
);
}

export function buildApp(revalidateSeconds: number) {
const app = createRouter();

// SSG: render once at startup; the route serves the baked string.
const items: string[] = [];
for (const p of POSTS) {
items.push(h("li", [], [h("a", ["href", "/post/" + p.slug], [escapeHtml(p.title)])]));
}
const indexMarkup = Layout("posts", h("ul", [], items));
app.get("/", (c) => c.html(indexMarkup));

// ISR: one cached document per slug, re-rendered after revalidateSeconds.
const postPages: { slug: string; view: IsrPage }[] = [];
for (const post of POSTS) {
let renders = 0;
postPages.push({
slug: post.slug,
view: new IsrPage(() => {
renders++;
return Layout(post.title, PostCard(post) + h("p", [], [escapeHtml("render " + renders)]));
}, revalidateSeconds),
});
}
app.get("/post/:slug", (c) => {
const slug = c.req.param("slug");
for (const p of postPages) {
if (p.slug === slug) {
return c.html(p.view.html());
}
}
return c.status(404).html(Layout("404", h("p", [], ["no such post"])));
});

return app;
}
3 changes: 3 additions & 0 deletions examples/pages/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { buildApp } from "./app.ts";

buildApp(30).serve({ port: Number(process.argv[2] || 3000) });
40 changes: 40 additions & 0 deletions examples/pages/test/pages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../src/app.ts";

async function serve(app: ReturnType<typeof buildApp>) {
const server = app.serve({ port: 0 });
await new Promise((r) => server.once("listening", r));
const port = (server.address() as { port: number }).port;
return { server, url: "http://127.0.0.1:" + port };
}

test("index is a full document rendered once (SSG)", async () => {
const { server, url } = await serve(buildApp(60));
const a = await (await fetch(url + "/")).text();
const b = await (await fetch(url + "/")).text();
server.close();
assert.ok(a.startsWith("<!doctype html>"));
assert.ok(a.includes('<a href="/post/hello">Hello, pages</a>'));
assert.equal(a, b);
});

test("post page is cached inside the revalidate window, re-rendered after (ISR)", async () => {
const { server, url } = await serve(buildApp(0.05));
const one = await (await fetch(url + "/post/isr")).text();
assert.ok(one.includes("render 1"));
const two = await (await fetch(url + "/post/isr")).text();
assert.equal(two, one);
await new Promise((r) => setTimeout(r, 60));
const three = await (await fetch(url + "/post/isr")).text();
server.close();
assert.ok(three.includes("render 2"));
});

test("unknown slug renders a 404 page", async () => {
const { server, url } = await serve(buildApp(60));
const res = await fetch(url + "/post/nope");
server.close();
assert.equal(res.status, 404);
assert.ok((await res.text()).includes("no such post"));
});
2 changes: 1 addition & 1 deletion packages/html/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "pnpm run build && node --test test/html.test.js test/jsx-runtime.test.js"
"test": "pnpm run build && node --test test/html.test.js test/jsx-runtime.test.js test/page.test.js"
},
"devDependencies": {
"@types/node": "^24.0.0",
Expand Down
39 changes: 39 additions & 0 deletions packages/html/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,42 @@ export function raw(strings: TemplateStringsArray, ...vals: string[]): string {
}
return out;
}

// Full document shell: doctype + <html><head>head</head><body>body</body></html>.
// head and body are pre-rendered markup, same contract as h() children.
export function page(head: string, body: string): string {
return "<!doctype html>" + h("html", [], [h("head", [], [head]), h("body", [], [body])]);
}

// TTL-cached page markup (ISR semantics): render once, serve the cached string,
// re-render lazily on the first read after the revalidate window expires.
// Revalidation is synchronous like Next.js's blocking ISR fallback; there is no
// background refresh inside the subset (no timers for cache work).
export class IsrPage {
private readonly render: () => string;
private readonly seconds: number;
private body: string;
private until: number;

constructor(render: () => string, revalidateSeconds: number) {
this.render = render;
this.seconds = revalidateSeconds;
this.body = render();
this.until = Date.now() + revalidateSeconds * 1000;
}

// Cached markup; re-renders when the revalidate window has elapsed.
html(): string {
const now = Date.now();
if (now >= this.until) {
this.body = this.render();
this.until = now + this.seconds * 1000;
}
return this.body;
}

// Epoch ms at which the next read re-renders (for tests and Cache-Control math).
freshUntil(): number {
return this.until;
}
}
53 changes: 53 additions & 0 deletions packages/html/test/page.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { IsrPage, h, page } from "../dist/esm/index.js";

test("page wraps head and body in a full document", () => {
const out = page(h("title", [], ["Hi"]), h("h1", [], ["Hello"]));
assert.equal(
out,
"<!doctype html><html><head><title>Hi</title></head><body><h1>Hello</h1></body></html>",
);
});

test("page emits empty head/body when passed empty strings", () => {
assert.equal(page("", ""), "<!doctype html><html><head></head><body></body></html>");
});

test("page does not escape head or body (they are markup)", () => {
const out = page('<meta charset="utf-8"/>', "<p>x</p>");
assert.ok(out.includes('<meta charset="utf-8"/>'));
assert.ok(out.includes("<p>x</p>"));
});

test("IsrPage renders once and serves the cached string", () => {
let renders = 0;
const p = new IsrPage(() => {
renders++;
return "r" + renders;
}, 60);
assert.equal(renders, 1);
assert.equal(p.html(), "r1");
assert.equal(p.html(), "r1");
assert.equal(renders, 1);
});

test("IsrPage re-renders after the revalidate window", async () => {
let renders = 0;
const p = new IsrPage(() => {
renders++;
return "r" + renders;
}, 0.05);
assert.equal(p.html(), "r1");
await new Promise((r) => setTimeout(r, 60));
assert.equal(p.html(), "r2");
assert.equal(renders, 2);
assert.equal(p.html(), "r2");
});

test("IsrPage freshUntil reports the next re-render instant", () => {
const before = Date.now();
const p = new IsrPage(() => "x", 30);
assert.ok(p.freshUntil() >= before + 30000 - 50);
assert.ok(p.freshUntil() <= Date.now() + 30000 + 50);
});
Loading
Loading