From 014e33eb35231ea36321936b4711fa967e2c7b6c Mon Sep 17 00:00:00 2001
From: Quim T <26749475+espetro@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:46:29 +0000
Subject: [PATCH 1/4] feat(html): page() document shell + IsrPage revalidate
cache (issue #14 slice)
---
.changeset/pages-ssr-slice.md | 5 ++++
bench/apps/chttp-html.ts | 13 ++++++--
packages/html/package.json | 2 +-
packages/html/src/index.ts | 39 ++++++++++++++++++++++++
packages/html/test/page.test.js | 53 +++++++++++++++++++++++++++++++++
5 files changed, 109 insertions(+), 3 deletions(-)
create mode 100644 .changeset/pages-ssr-slice.md
create mode 100644 packages/html/test/page.test.js
diff --git a/.changeset/pages-ssr-slice.md b/.changeset/pages-ssr-slice.md
new file mode 100644
index 0000000..303a019
--- /dev/null
+++ b/.changeset/pages-ssr-slice.md
@@ -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`.
diff --git a/bench/apps/chttp-html.ts b/bench/apps/chttp-html.ts
index 9a0c7e4..8bc7f6d 100644
--- a/bench/apps/chttp-html.ts
+++ b/bench/apps/chttp-html.ts
@@ -4,6 +4,7 @@ import { createRouter } from "../../packages/router/src/index.ts";
import {
BARE,
Fragment,
+ IsrPage,
createElement,
escapeHtml,
h,
@@ -11,6 +12,7 @@ import {
isValidAttrName,
isValidTag,
isVoidElement,
+ page,
raw,
} from "../../packages/html/src/index.ts";
@@ -23,7 +25,7 @@ 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`
${name}
`),
createElement("input", ["disabled", BARE, "value", name]),
@@ -31,7 +33,14 @@ app.get("/page", (c) => {
h("div", ["safe", "true"], ["" + name + ""]),
]);
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`cached
`)),
+ 30,
+);
+
+app.get("/isr", (c) => c.html(index.html() + index.freshUntil()));
+
app.serve({ port: Number(process.argv[2] || 3000) });
diff --git a/packages/html/package.json b/packages/html/package.json
index b54c462..c74d5c1 100644
--- a/packages/html/package.json
+++ b/packages/html/package.json
@@ -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",
diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts
index ab7c039..2180d6a 100644
--- a/packages/html/src/index.ts
+++ b/packages/html/src/index.ts
@@ -202,3 +202,42 @@ export function raw(strings: TemplateStringsArray, ...vals: string[]): string {
}
return out;
}
+
+// Full document shell: doctype + headbody.
+// head and body are pre-rendered markup, same contract as h() children.
+export function page(head: string, body: string): string {
+ return "" + 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;
+ }
+}
diff --git a/packages/html/test/page.test.js b/packages/html/test/page.test.js
new file mode 100644
index 0000000..e3a62bf
--- /dev/null
+++ b/packages/html/test/page.test.js
@@ -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,
+ "HiHello
",
+ );
+});
+
+test("page emits empty head/body when passed empty strings", () => {
+ assert.equal(page("", ""), "");
+});
+
+test("page does not escape head or body (they are markup)", () => {
+ const out = page('', "x
");
+ assert.ok(out.includes(''));
+ assert.ok(out.includes("x
"));
+});
+
+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);
+});
From 632f367cea915d4aadd9604cf07348a8073b663c Mon Sep 17 00:00:00 2001
From: Quim T <26749475+espetro@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:46:29 +0000
Subject: [PATCH 2/4] =?UTF-8?q?examples:=20pages=20=E2=80=94=20SSG=20index?=
=?UTF-8?q?=20+=20per-slug=20ISR=20over=20@chttp/html?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
examples/pages/README.md | 16 +++++++
examples/pages/package.json | 19 ++++++++
examples/pages/src/app.ts | 68 +++++++++++++++++++++++++++
examples/pages/src/main.ts | 3 ++
examples/pages/test/pages.test.ts | 40 ++++++++++++++++
pnpm-lock.yaml | 77 ++++++++++++++++++-------------
pnpm-workspace.yaml | 2 +
7 files changed, 193 insertions(+), 32 deletions(-)
create mode 100644 examples/pages/README.md
create mode 100644 examples/pages/package.json
create mode 100644 examples/pages/src/app.ts
create mode 100644 examples/pages/src/main.ts
create mode 100644 examples/pages/test/pages.test.ts
diff --git a/examples/pages/README.md b/examples/pages/README.md
new file mode 100644
index 0000000..d5461b0
--- /dev/null
+++ b/examples/pages/README.md
@@ -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
+```
diff --git a/examples/pages/package.json b/examples/pages/package.json
new file mode 100644
index 0000000..da09f4f
--- /dev/null
+++ b/examples/pages/package.json
@@ -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"
+ }
+}
diff --git a/examples/pages/src/app.ts b/examples/pages/src/app.ts
new file mode 100644
index 0000000..92fd6bf
--- /dev/null
+++ b/examples/pages/src/app.ts
@@ -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, html, 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)]), html`${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) + html`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;
+}
diff --git a/examples/pages/src/main.ts b/examples/pages/src/main.ts
new file mode 100644
index 0000000..8f14d99
--- /dev/null
+++ b/examples/pages/src/main.ts
@@ -0,0 +1,3 @@
+import { buildApp } from "./app.ts";
+
+buildApp(30).serve({ port: Number(process.argv[2] || 3000) });
diff --git a/examples/pages/test/pages.test.ts b/examples/pages/test/pages.test.ts
new file mode 100644
index 0000000..9aff7db
--- /dev/null
+++ b/examples/pages/test/pages.test.ts
@@ -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) {
+ 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(""));
+ assert.ok(a.includes('Hello, pages'));
+ 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"));
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2f572cc..c7c5b11 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -357,6 +357,19 @@ importers:
specifier: '>=0.3.2'
version: 0.3.3
+ examples/pages:
+ dependencies:
+ '@chttp/html':
+ specifier: workspace:*
+ version: link:../../packages/html
+ '@chttp/router':
+ specifier: workspace:*
+ version: link:../../packages/router
+ devDependencies:
+ scriptc:
+ specifier: '>=0.3.2'
+ version: 0.3.6
+
examples/rate-limit:
dependencies:
'@chttp/router':
@@ -525,7 +538,7 @@ importers:
version: 5.9.3
typescript-next:
specifier: npm:typescript@next
- version: typescript@7.1.0-dev.20261016.1
+ version: typescript@7.1.0-dev.20261017.1
packages/val:
devDependencies:
@@ -1562,44 +1575,44 @@ packages:
'@types/picomatch@4.0.3':
resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==}
- '@typescript/typescript-darwin-arm64@7.1.0-dev.20261016.1':
- resolution: {integrity: sha512-O6EDZUrSRcvS0oIcP6F1VuuIAPWRNgfly/pqCz2mFgLvksF6UoEwwT82+JFpHmYtVWSzDXA+ryfnDjz8ecowOA==}
+ '@typescript/typescript-darwin-arm64@7.1.0-dev.20261017.1':
+ resolution: {integrity: sha512-IjfOdppCkPOGB1oXw5gzP3k2ymBQsmOmaNN1ILoqtT4J8KpxP68sJxF0yoDXoZfqVAwItXXRQtflUg2NobP3AA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [darwin]
- '@typescript/typescript-darwin-x64@7.1.0-dev.20261016.1':
- resolution: {integrity: sha512-O1DsyFcs9d5tsLGbS2jnM9PoQTuR6g0n5d5KacOk62FvXnU9I0cfl9VRUtpmiF1qHxfR1uMJRQFVcpzzJbCClw==}
+ '@typescript/typescript-darwin-x64@7.1.0-dev.20261017.1':
+ resolution: {integrity: sha512-BHLQXCS75YjW9ncDITmaWQaH/uKMNDtx3W2lzyjxMi1wzS1HaA8YigFikm707SRiQ5vzChsUcA2dpBy2umDdVQ==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [darwin]
- '@typescript/typescript-linux-arm64@7.1.0-dev.20261016.1':
- resolution: {integrity: sha512-cR8Rx/83YFiLw+tMYp0iirtMYUobMCFlysMmkqstdAybexxuQxKVdnU71TiDHce5yxX2yPxcMVQ2Im4GSJ7B8g==}
+ '@typescript/typescript-linux-arm64@7.1.0-dev.20261017.1':
+ resolution: {integrity: sha512-yVDp96vSAlCCwQ/khZTFAQAD1wQayuntYuba3Xz5CLZWBKEjkcn3JmyyglD3NfILlskuv2y+kuTm6JC9cy4k7g==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [linux]
- '@typescript/typescript-linux-arm@7.1.0-dev.20261016.1':
- resolution: {integrity: sha512-jwlrwkHtrgaXAdrKivl/Ra5uSQ2BMjS4Uvqz9BO3rKJK6NSOoFu8xhrLcBh4CVOQnOt7cdyrrv8lb45C80yj3w==}
+ '@typescript/typescript-linux-arm@7.1.0-dev.20261017.1':
+ resolution: {integrity: sha512-ENQsw93mv3f7wcfoKGhuAaMGfXfpOxYGdnLjWwHPSAqRQWZeym/wEZFWa0yQylOTvmsbkk3q7VWACL5CTh5+xA==}
engines: {node: '>=16.20.0'}
cpu: [arm]
os: [linux]
- '@typescript/typescript-linux-x64@7.1.0-dev.20261016.1':
- resolution: {integrity: sha512-7zqdh6fTPbbSCs6XE9XijZ+/n1GRzPI9tcoDw1ylcMm2olA99p6TtwXvAizWTNhPmIn+nF0m26ht2Qaydd2B7Q==}
+ '@typescript/typescript-linux-x64@7.1.0-dev.20261017.1':
+ resolution: {integrity: sha512-kTmcAbZRuOywbOS4TwylKI4QjhdX/R3T+WdDaIhOhVmHlO2y9HPFF6amWxfa2/FlaMHhRFxaD8gniUrz1vcLpw==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [linux]
- '@typescript/typescript-win32-arm64@7.1.0-dev.20261016.1':
- resolution: {integrity: sha512-rsfmsiuLcOz6HeUpWlVROURK/hiElZ6/HnO27eWTDWqt3moPbq6EsaM3P3EChQA+nxaWdXYA2eJlllfoN/SALw==}
+ '@typescript/typescript-win32-arm64@7.1.0-dev.20261017.1':
+ resolution: {integrity: sha512-1GiiEr2vygWZPhy3I1wD8jxN0MRr4iZrAK7EyI+dAQsPUbv4KM4ZUdzkpkx8PCGOwpdxN8yNbAPl/0FFZbrw2Q==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [win32]
- '@typescript/typescript-win32-x64@7.1.0-dev.20261016.1':
- resolution: {integrity: sha512-ezvLsq1zhUhYrVNmp7MSXPnihN1pixsJbdmt3CENPi2IvDh4E7pU+ob3KcbjkzxOodDVuHrX4UsMeFnkXRfVDw==}
+ '@typescript/typescript-win32-x64@7.1.0-dev.20261017.1':
+ resolution: {integrity: sha512-BkEB741BL8QNLFnPehRLhXgHibE1/E9M6++9f4eA15MQAn7gItz2ITJYhaQoCVkauH4x8lQAJlqxY3sdGChJrA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [win32]
@@ -2398,8 +2411,8 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
- typescript@7.1.0-dev.20261016.1:
- resolution: {integrity: sha512-LwBVvNdVXO4Jw5omuucllx2SkVRo086LeIWqmoUAwRZfL309noSdmAzuQqO49kVvll5sN6Yxu5jX5FrKgqew+w==}
+ typescript@7.1.0-dev.20261017.1:
+ resolution: {integrity: sha512-d8K6oDCprwXc9c2A9Miu9J+JeyN7kK+QGijoyGS9uA1KKzMoGer9+5df77k/mFQ5y7TQJQ8quWDsKkSCvcdIjw==}
engines: {node: '>=16.20.0'}
hasBin: true
@@ -3255,25 +3268,25 @@ snapshots:
'@types/picomatch@4.0.3': {}
- '@typescript/typescript-darwin-arm64@7.1.0-dev.20261016.1':
+ '@typescript/typescript-darwin-arm64@7.1.0-dev.20261017.1':
optional: true
- '@typescript/typescript-darwin-x64@7.1.0-dev.20261016.1':
+ '@typescript/typescript-darwin-x64@7.1.0-dev.20261017.1':
optional: true
- '@typescript/typescript-linux-arm64@7.1.0-dev.20261016.1':
+ '@typescript/typescript-linux-arm64@7.1.0-dev.20261017.1':
optional: true
- '@typescript/typescript-linux-arm@7.1.0-dev.20261016.1':
+ '@typescript/typescript-linux-arm@7.1.0-dev.20261017.1':
optional: true
- '@typescript/typescript-linux-x64@7.1.0-dev.20261016.1':
+ '@typescript/typescript-linux-x64@7.1.0-dev.20261017.1':
optional: true
- '@typescript/typescript-win32-arm64@7.1.0-dev.20261016.1':
+ '@typescript/typescript-win32-arm64@7.1.0-dev.20261017.1':
optional: true
- '@typescript/typescript-win32-x64@7.1.0-dev.20261016.1':
+ '@typescript/typescript-win32-x64@7.1.0-dev.20261017.1':
optional: true
'@valibot/to-json-schema@1.8.0(valibot@1.5.0(typescript@5.9.3))':
@@ -4189,15 +4202,15 @@ snapshots:
typescript@5.9.3: {}
- typescript@7.1.0-dev.20261016.1:
+ typescript@7.1.0-dev.20261017.1:
optionalDependencies:
- '@typescript/typescript-darwin-arm64': 7.1.0-dev.20261016.1
- '@typescript/typescript-darwin-x64': 7.1.0-dev.20261016.1
- '@typescript/typescript-linux-arm': 7.1.0-dev.20261016.1
- '@typescript/typescript-linux-arm64': 7.1.0-dev.20261016.1
- '@typescript/typescript-linux-x64': 7.1.0-dev.20261016.1
- '@typescript/typescript-win32-arm64': 7.1.0-dev.20261016.1
- '@typescript/typescript-win32-x64': 7.1.0-dev.20261016.1
+ '@typescript/typescript-darwin-arm64': 7.1.0-dev.20261017.1
+ '@typescript/typescript-darwin-x64': 7.1.0-dev.20261017.1
+ '@typescript/typescript-linux-arm': 7.1.0-dev.20261017.1
+ '@typescript/typescript-linux-arm64': 7.1.0-dev.20261017.1
+ '@typescript/typescript-linux-x64': 7.1.0-dev.20261017.1
+ '@typescript/typescript-win32-arm64': 7.1.0-dev.20261017.1
+ '@typescript/typescript-win32-x64': 7.1.0-dev.20261017.1
uc.micro@2.1.0: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 5e03f75..ff99160 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -4,6 +4,7 @@ packages:
- examples/sse-clock
- examples/ws-echo
- examples/rate-limit
+ - examples/pages
- examples/backpressure
- examples/integrations/*
- bench
@@ -28,6 +29,7 @@ versioning:
- chttp-example-logging-file-audit
- chttp-example-otlp-traces
- chttp-example-prometheus-metrics
+ - chttp-example-pages
- chttp-example-rate-limit
- chttp-example-sse-clock
- chttp-example-webhook-server
From 21b214c89326d34bc3154cf0820861d8d62ad17c Mon Sep 17 00:00:00 2001
From: Quim T <26749475+espetro@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:46:29 +0000
Subject: [PATCH 3/4] docs(html): pages/SSG/ISR section + issue #14 slice plan
---
.agents/plans/2026-10-19-issue14-pages.md | 16 +++++++
docs/html.md | 51 ++++++++++++++++++-----
2 files changed, 57 insertions(+), 10 deletions(-)
create mode 100644 .agents/plans/2026-10-19-issue14-pages.md
diff --git a/.agents/plans/2026-10-19-issue14-pages.md b/.agents/plans/2026-10-19-issue14-pages.md
new file mode 100644
index 0000000..2f50256
--- /dev/null
+++ b/.agents/plans/2026-10-19-issue14-pages.md
@@ -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 (`` + 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.
diff --git a/docs/html.md b/docs/html.md
index 586663a..6854ab1 100644
--- a/docs/html.md
+++ b/docs/html.md
@@ -4,16 +4,16 @@
## API
-| Export | Signature | Notes |
-| --- | --- | --- |
-| `escapeHtml` | `(s: string) => string` | Escapes `& < " '` to `& < " '`, 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 `& < " '`, 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";
@@ -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: `` + `` + ``. 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.
From b29947583d52090e4b9a5d064762c0fe417336e7 Mon Sep 17 00:00:00 2001
From: Quim T <26749475+espetro@users.noreply.github.com>
Date: Mon, 19 Oct 2026 07:49:20 +0000
Subject: [PATCH 4/4] =?UTF-8?q?fix(examples/pages):=20drop=20html=20tag=20?=
=?UTF-8?q?inside=20IsrPage=20closure=20=E2=80=94=20SC1090=20cascades=20to?=
=?UTF-8?q?=20SC2013=20on=20@chttp/router?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
examples/pages/src/app.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/examples/pages/src/app.ts b/examples/pages/src/app.ts
index 92fd6bf..7a56c83 100644
--- a/examples/pages/src/app.ts
+++ b/examples/pages/src/app.ts
@@ -1,7 +1,7 @@
// 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, html, page } from "@chttp/html";
+import { IsrPage, escapeHtml, h, page } from "@chttp/html";
import { createRouter } from "@chttp/router";
interface Post {
@@ -27,7 +27,7 @@ function PostCard(post: Post): string {
return h(
"article",
["class", "post"],
- [h("h1", [], [escapeHtml(post.title)]), html`${post.body}
`],
+ [h("h1", [], [escapeHtml(post.title)]), h("p", [], [escapeHtml(post.body)])],
);
}
@@ -50,7 +50,7 @@ export function buildApp(revalidateSeconds: number) {
slug: post.slug,
view: new IsrPage(() => {
renders++;
- return Layout(post.title, PostCard(post) + html`render ${renders}
`);
+ return Layout(post.title, PostCard(post) + h("p", [], [escapeHtml("render " + renders)]));
}, revalidateSeconds),
});
}