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, + "Hi

Hello

", + ); +}); + +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), }); }