diff --git a/AGENTS.md b/AGENTS.md index 61025dc69..7077581a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Deploy hosted production before publishing the npm packages: the CLI checks the ## Best Practices That Must Be Followed - **Do not use Python for repository scripts or automation**, including inline CI scripts and ad-hoc maintenance commands. Use Node.js with TypeScript/JavaScript, or shell/PowerShell where appropriate. -- **Keep user-facing documentation focused on using Void.** Explain public APIs, configuration, observable behavior, and actions users need to take. Do not add implementation details, internal workarounds, defensive checks, normalization rules, or bug-fix history unless they help users make a decision or resolve an actionable problem. Maintainer-only knowledge belongs in source comments, repository/package `AGENTS.md`, or contributor guides. This applies to app guides, API/CLI references, consumer-facing READMEs, and shipped app-development skills. +- **Keep user-facing documentation focused on using Void.** Explain public APIs, configuration, observable behavior, and actions users need to take. Do not add implementation details, internal workarounds, defensive checks, normalization rules, or bug-fix history unless they help users make a decision or resolve an actionable problem. Maintainer-only knowledge belongs in source comments, repository/package `AGENTS.md`, or contributor guides. This applies to app guides, API/CLI references, consumer-facing READMEs, and shipped app-development skills. Code examples should type-check. When an API does not infer a route, param, or path type, annotate the example with the public type that checks it, such as `PageParams`, `RouteUrl`, or `routeUrl()`. - **Keep Pages examples framework-agnostic.** Void Pages supports React, Vue, Svelte, and Solid. When a docs example uses an adapter API and the syntax differs, show all four in a `::: code-group` with those tab labels. When the code is the same, write it once; if only the import path differs, name the other packages in a comment (`@void/react`, `@void/vue`, `@void/svelte`, `@void/solid`). Do not use one framework's component syntax, import, or file extension as the only example. Keep a single framework when the section is that framework's own API, a labeled custom SSR renderer, a JSX route handler, or an integration guide. Do not wrap a one-snippet example in a code group. - **Keep native Cloudflare deploys Workers Free-compatible by default.** New public Void SDK/runtime features must work with `void deploy --platform cloudflare` on Workers Free unless the underlying Cloudflare product has no Free offering. Paid-only functionality must be explicitly opt-in, inferred and emitted only when the app uses it, and checked before provisioning or building. Its documentation and deploy error must name the required Cloudflare product/plan, link to enable it, and explain any API-token permissions. Platform-internal paid services must never become requirements of a single-app native Cloudflare deploy. Add regression coverage proving that an app which does not use the paid feature emits no paid binding and performs no entitlement probe. - Every implementation plan should include the following: diff --git a/docs/guide/edge/revalidation.md b/docs/guide/edge/revalidation.md index ff89a9014..78c75085d 100644 --- a/docs/guide/edge/revalidation.md +++ b/docs/guide/edge/revalidation.md @@ -119,13 +119,15 @@ Purge the destination pathname with `revalidate({ paths })`. Purging `/en/docs/f You can purge ISR cache entries programmatically from a route handler: ```ts +import { defineHandler } from 'void'; import { revalidate } from 'void/isr'; +import type { RouteUrl } from 'void/routes'; export const POST = defineHandler(async (c) => { // ... update content in database ... - // Purge specific pages - await revalidate({ paths: ['/', '/blog/hello-world'] }); + // Purge specific pages. `satisfies RouteUrl[]` checks the paths when routing.typedRoutes is on. + await revalidate({ paths: ['/', '/blog/hello-world'] satisfies RouteUrl[] }); // Or purge all cached pages await revalidate({ all: true }); diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md index 1b12747ce..906876701 100644 --- a/docs/guide/pages-routing/actions-and-forms.md +++ b/docs/guide/pages-routing/actions-and-forms.md @@ -24,10 +24,10 @@ export const action = defineHandler.withValidator({ }); ``` -| Action returns | Behavior | -| --------------------- | ----------------------------------------------------- | -| Nothing (void) | Re-runs the loader, page re-renders with fresh props. | -| `c.redirect('/path')` | Navigates to another page. | +| Action returns | Behavior | +| ----------------- | ------------------------------------------------------------------------------------------------------------ | +| Nothing (void) | Re-runs the loader, page re-renders with fresh props. | +| `c.redirect(url)` | Navigates to another page. Build `url` with `routeUrl()`, or annotate a full path with `satisfies RouteUrl`. | Middleware does not run again after the action. To change [shared data](./layouts.md#shared-data), update it in the action or redirect. @@ -227,19 +227,22 @@ When a page needs multiple mutations, such as updating and deleting a user on th import { users, updateUserSchema } from '@schema'; import { defineHandler } from 'void'; import { db, eq } from 'void/db'; +import type { PageParams } from 'void/routes'; export const actions = { update: defineHandler.withValidator({ body: updateUserSchema, })(async (c, { body }) => { + const { id } = c.req.param() as PageParams<'/users/:id'>; await db .update(users) .set(body) - .where(eq(users.id, Number(c.req.param('id')))); + .where(eq(users.id, Number(id))); }), delete: defineHandler(async (c) => { - await db.delete(users).where(eq(users.id, Number(c.req.param('id')))); + const { id } = c.req.param() as PageParams<'/users/:id'>; + await db.delete(users).where(eq(users.id, Number(id))); }), }; ``` diff --git a/docs/guide/pages-routing/head.md b/docs/guide/pages-routing/head.md index a8d572a10..53119bd81 100644 --- a/docs/guide/pages-routing/head.md +++ b/docs/guide/pages-routing/head.md @@ -13,13 +13,15 @@ Export a `head()` function from your `.server.ts` file. It receives the Hono con ```ts // pages/posts/[slug].server.ts import { defineHandler, defineHead } from 'void'; +import type { PageParams } from 'void/routes'; export interface Props { post: { title: string; excerpt: string; slug: string }; } export const loader = defineHandler(async (c) => { - const post = await getPost(c.req.param('slug')); + const { slug } = c.req.param() as PageParams<'/posts/:slug'>; + const post = await getPost(slug); return { post }; }); diff --git a/docs/guide/type-safety.md b/docs/guide/type-safety.md index c9d44a8a2..561daab29 100644 --- a/docs/guide/type-safety.md +++ b/docs/guide/type-safety.md @@ -224,6 +224,14 @@ The pattern must be one of your pages; HTTP and WebSocket routes are not accepte The pattern is checked by TypeScript only. Pass the pattern of the page the component renders in; without a pattern, `useParams()` returns `Record`. +On the server, `c.req.param('slug')` accepts any name. Read the params object through `PageParams` so a missing or misspelled name is a type error: + +```ts +import type { PageParams } from 'void/routes'; + +const { slug } = c.req.param() as PageParams<'/posts/:slug'>; +``` + ### Prerender paths On a dynamic page, annotate `getPrerenderPaths()` with `PageParams` for that page's URL pattern. Each returned object must then include the page's params, and a missing or misspelled key is a type error. Void does not infer this type from the filename, so the check applies only when the annotation is present: @@ -239,6 +247,14 @@ export async function getPrerenderPaths(): Promise[]> In custom SSR mode the function returns full path strings, so annotate it as `Promise`. See [Edge Prerendering](./edge/prerendering.md#dynamic-pages-with-params) and [Static Site Generation](./ssg.md#per-page-overrides). +`revalidate({ paths })` and `routing.prerender` are lists of plain strings. Annotate the array with `satisfies RouteUrl[]` so each path has to match a route. The check applies when [`routing.typedRoutes`](../reference/config.md#routing-typedroutes) is on; otherwise `RouteUrl` is `string`: + +```ts +import type { RouteUrl } from 'void/routes'; + +await revalidate({ paths: ['/', '/blog/hello-world'] satisfies RouteUrl[] }); +``` + ### Checked URL strings [`routing.typedRoutes`](../reference/config.md#routing-typedroutes) checks plain URL strings in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, and `c.rewrite()`. Apps created with `void init` have it on. To turn it on in an existing app: @@ -308,9 +324,19 @@ import { routeUrl } from 'void/routes'; Links can also point to the current page (`?page=2`, `#comments`) and to `http://`, `https://`, `mailto:`, and `tel:` URLs. Relative paths such as `../settings` are rejected; use the absolute path. A fixed part with spaces or non-ASCII characters can be written as is (`/café menu`) or percent-encoded in uppercase, as `routeUrl()` returns it (`/caf%C3%A9%20menu`). A dynamic segment matches any text, including none, so `/users/:id` also accepts `/users/` and `/users/42/edit`. With a root dynamic page such as `pages/[slug].tsx`, `.vue`, or `.svelte`, a protocol-relative URL like `//example.com` passes too. +`c.redirect()` is not part of that check. It still accepts any string. Build an internal path with `routeUrl()`, or annotate a full path with `satisfies RouteUrl`. With `routing.typedRoutes` on, a path that matches no route is a type error. An external URL stays a plain string: + +```ts +import { routeUrl, type RouteUrl } from 'void/routes'; + +c.redirect(routeUrl('/users/:id', { params: { id } })); +c.redirect('/users/42' satisfies RouteUrl); +c.redirect('https://example.com/login'); +``` + `void/routes` exports two types for these strings: -- `RouteUrl`: the URL of one of your routes. `routeUrl()` returns it, and `c.rewrite()` accepts it. +- `RouteUrl`: the URL of one of your routes. `routeUrl()` returns it, and `c.rewrite()` accepts it. Annotate a `c.redirect()` path or a prerender or revalidate path list with it. - `Href`: anything a link can point to: a `RouteUrl`, a same-page query or hash, or an external URL. Cast a string that TypeScript can't check, such as a path from your CMS or a file in `public/`: @@ -376,8 +402,11 @@ Route types update with the dev server. For CI or standalone typechecking, run ` | Fetch client | Route paths, HTTP methods, `body`/`query`/`params`, response type | | Navigation | Route patterns and required parameter names and values in `routeUrl()` and destination objects | | Links | With `routing.typedRoutes`, URL strings in `Link`, router methods, and `c.rewrite()` | +| `c.redirect` | An internal path built with `routeUrl()`, or a full path annotated with `satisfies RouteUrl` | | `useParams` | Page patterns and the names of the params they return | +| `c.req.param` | Page param names, when the result is annotated with `PageParams` | | `getPrerenderPaths` | Page param names, when the return type is annotated with `PageParams` | +| `revalidate` | `paths`, and `routing.prerender`, when the array is annotated with `satisfies RouteUrl[]` | | `useForm` | Action URLs, `data` fields, `errors` keys, `reset()` args (including `{ initial: true }`), `params`, `resetOnSuccess` | | `action()` | Action URLs, `data` payload, `params` | diff --git a/docs/reference/api/handlers.md b/docs/reference/api/handlers.md index 35cab9bb3..70d886470 100644 --- a/docs/reference/api/handlers.md +++ b/docs/reference/api/handlers.md @@ -228,11 +228,13 @@ Type-safe wrapper for the page `head()` export in `.server.ts` files. Provides [ ```ts import { defineHandler, defineHead } from 'void'; import type { InferProps } from 'void'; +import type { PageParams } from 'void/routes'; export type Props = InferProps; export const loader = defineHandler(async (c) => { - const post = await getPost(c.req.param('slug')); + const { slug } = c.req.param() as PageParams<'/posts/:slug'>; + const post = await getPost(slug); return { post }; }); diff --git a/docs/reference/api/resources.md b/docs/reference/api/resources.md index f8935e8aa..def494cd7 100644 --- a/docs/reference/api/resources.md +++ b/docs/reference/api/resources.md @@ -104,11 +104,14 @@ Revalidate ISR-cached pages on demand. In local development this is a no-op beca ```ts import { revalidate } from 'void/isr'; +import type { RouteUrl } from 'void/routes'; -await revalidate({ paths: ['/', '/blog/hello'] }); +await revalidate({ paths: ['/', '/blog/hello'] satisfies RouteUrl[] }); await revalidate({ all: true }); ``` +`paths` is `string[]`. Annotate the array with `satisfies RouteUrl[]` so TypeScript checks each path when [`routing.typedRoutes`](../config.md#routing-typedroutes) is on. + **Signature:** ```ts diff --git a/docs/reference/api/rewrites.md b/docs/reference/api/rewrites.md index 82a4e6664..6065fdf72 100644 --- a/docs/reference/api/rewrites.md +++ b/docs/reference/api/rewrites.md @@ -22,12 +22,14 @@ return c.rewrite({ With [`routing.typedRoutes`](../config.md#routing-typedroutes), a string destination must be a [`RouteUrl`](./types.md#route-types): a URL that matches one of your routes. Cast a path built at runtime, such as ``c.rewrite(`/${locale}${path}` as RouteUrl)``. -For a typed redirect, build the URL with [`routeUrl()`](./types.md#routeurl-path-options): +For a typed redirect, build the URL with [`routeUrl()`](./types.md#routeurl-path-options). `c.redirect()` still accepts any string, so a full path is checked only when you annotate it with [`RouteUrl`](./types.md#route-types). That check applies when [`routing.typedRoutes`](../config.md#routing-typedroutes) is on. An external URL stays a plain string: ```ts -import { routeUrl } from 'void/routes'; +import { routeUrl, type RouteUrl } from 'void/routes'; return c.redirect(routeUrl('/users/:id', { params: { id: 42 } })); +return c.redirect('/users/42' satisfies RouteUrl); +return c.redirect('https://example.com/login'); ``` `c.redirect()` sends the location it is given. The request query is not added to an internal path, a protocol-relative URL, or an absolute URL, so `c.redirect('/notes')` from `/sign-in?redirectTo=/notes` goes to `/notes`. [`routing.redirects`](../../guide/edge/redirects.md) still merge the request query into the destination. diff --git a/docs/reference/config.md b/docs/reference/config.md index 02c785168..d16bee826 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -378,7 +378,7 @@ routing: { #### `routing.typedRoutes` -Type-check URL strings against your app's routes in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, and `c.rewrite()`. Omitted or `false` means off; `void init` turns it on for new apps. It changes types only, not navigation at runtime. Strings may also be same-page queries or hashes, or `http://`, `https://`, `mailto:`, and `tel:` URLs. See [Checked URL strings](../guide/type-safety#checked-url-strings). +Type-check URL strings against your app's routes in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, and `c.rewrite()`. Omitted or `false` means off; `void init` turns it on for new apps. It changes types only, not navigation at runtime. Strings may also be same-page queries or hashes, or `http://`, `https://`, `mailto:`, and `tel:` URLs. `c.redirect()`, `revalidate({ paths })`, and `routing.prerender` stay plain strings until you annotate them. See [Checked URL strings](../guide/type-safety#checked-url-strings). ```ts routing: { @@ -505,6 +505,14 @@ Paths to prerender as static HTML at deploy time. Each path must start with `/`. } ``` +In `void.config.ts`, annotate the array with [`RouteUrl`](./api/types.md#route-types) so TypeScript checks the paths when [`routing.typedRoutes`](#routing-typedroutes) is on: + +```ts +import type { RouteUrl } from 'void/routes'; + +prerender: ['/', '/about', '/pricing'] satisfies RouteUrl[], +``` + #### `routing.notFound` Override how the asset layer answers a request that matched no asset and no worker route. One of `"single-page-application"`, `"404-page"`, or `"none"`. If omitted, Void infers it — see [Static Assets](../guide/edge/static-assets.md#navigation-and-middleware).