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/.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/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. 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..7a56c83 --- /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, 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; +} 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: ReturnTypex
"); + 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); +}); 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