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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/guide/pages-routing/head.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,29 @@ export default defineMiddleware(async (c, next) => {
});
```

`headDefaults` also accepts `titleTemplate`, which replaces the config's template for the requests the middleware runs on. Use it to give one section of the site its own title suffix:

```ts
// middleware/admin.ts
import { defineMiddleware } from 'void';

export default defineMiddleware(async (c, next) => {
if (c.req.path.startsWith('/admin')) {
c.set('headDefaults', { ...c.get('headDefaults'), titleTemplate: '%s | Admin' });
}
await next();
});
```

A page returning `{ title: "Users" }` under `/admin` produces `<title>Users | Admin</title>`. Other pages keep the config's template.

## Precedence

When multiple layers provide head data, they merge with this precedence:

| Field | Behavior |
| ----------- | -------------------------------------------------------------------------------------------------------------------- |
| `title` | Page wins. `titleTemplate` from config wraps the winning title. |
| `title` | Page wins. `titleTemplate` from middleware, or else from config, wraps the winning title. |
| `meta` | Deduplicates by `name` or `property`. Page entries override matching keys, and non-conflicting entries are appended. |
| `link` | Concatenated: config first, then middleware, then page. |
| `script` | Concatenated: config first, then middleware, then page. |
Expand Down
6 changes: 3 additions & 3 deletions packages/void/src/pages/head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ export type HeadConfig = HeadDescriptor & {
/**
* Merge head descriptors with precedence: page > middleware > config.
*
* - `title`: page wins; `titleTemplate` from config wraps it (`%s` replaced)
* - `title`: page wins; `titleTemplate` from middleware, else config, wraps it (`%s` replaced)
* - `meta`: page overrides entries with same `name`/`property`; others appended
* - `link`/`script`: concatenated (config first, then middleware, then page)
* - `htmlAttrs`/`bodyAttrs`: shallow merge, page wins conflicts
*/
export function resolveHead(
configHead: HeadConfig | undefined,
middlewareHead: HeadDescriptor | undefined,
middlewareHead: HeadConfig | undefined,
pageHead: HeadDescriptor | undefined,
): HeadDescriptor | undefined {
if (!configHead && !middlewareHead && !pageHead) {
Expand All @@ -36,7 +36,7 @@ export function resolveHead(

const title = pageHead?.title ?? middlewareHead?.title ?? configHead?.title;
if (title !== undefined) {
const template = configHead?.titleTemplate;
const template = middlewareHead?.titleTemplate ?? configHead?.titleTemplate;
result.title = template ? template.replace('%s', title) : title;
}

Expand Down
6 changes: 3 additions & 3 deletions packages/void/src/pages/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ async function renderPageFailure(
let head: HeadDescriptor | undefined;
// Invalid shared/head input is not repairable by trying another component.
try {
head = resolveHead(configHead, c.get('headDefaults') as HeadDescriptor | undefined, undefined);
head = resolveHead(configHead, c.get('headDefaults') as HeadConfig | undefined, undefined);
serializePageData({ shared, head });
} catch (cause) {
throw new Error('Unable to render Pages error boundary', { cause });
Expand Down Expand Up @@ -700,7 +700,7 @@ async function runPageGet(
// Read at render time so per-route middleware can set them before next()
const shared = (c.get('shared') as Record<string, unknown> | undefined) ?? {};
const pageHead = headFn ? headFn(c, props) : undefined;
const middlewareHead = c.get('headDefaults') as HeadDescriptor | undefined;
const middlewareHead = c.get('headDefaults') as HeadConfig | undefined;
const head = resolveHead(configHead, middlewareHead, pageHead);
if (renderMode !== 'island') {
varyPagesResponse(c);
Expand Down Expand Up @@ -900,7 +900,7 @@ async function runPageMutation(
errors: Record<string, string> | null,
): PageObject {
const shared = (c.get('shared') as Record<string, unknown> | undefined) ?? {};
const middlewareHead = c.get('headDefaults') as HeadDescriptor | undefined;
const middlewareHead = c.get('headDefaults') as HeadConfig | undefined;
const pageHead = headFn ? headFn(c, props) : undefined;
const head = resolveHead(configHead, middlewareHead, pageHead);
return {
Expand Down
4 changes: 2 additions & 2 deletions packages/void/src/runtime/handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Context, Env, MiddlewareHandler, Next, TypedResponse } from 'hono';
import type { RedirectStatusCode } from 'hono/utils/http-status';
import type { RewriteDestination, RouteDestination, RouteUrl, RouteUrlInput } from 'void/routes';
import type { HeadDescriptor } from '../pages/head.ts';
import type { HeadConfig, HeadDescriptor } from '../pages/head.ts';
import type { AuthSession, AuthUser } from './auth.ts';
import { base64ToBytes } from './base64.ts';
import { executeComposedHandler, setHandlerComposition } from './handler-composition.ts';
Expand Down Expand Up @@ -36,7 +36,7 @@ type RewritePath = TypedRoutes extends true ? RouteUrl : RewriteDestination;
export interface CloudContextVariables {
user: AuthUser | null;
session: AuthSession | null;
headDefaults?: HeadDescriptor;
headDefaults?: HeadConfig;
[key: string]: unknown;
}

Expand Down
26 changes: 26 additions & 0 deletions packages/void/test/unit/pages-head.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,32 @@ describe('resolveHead', () => {
expect(resolveHead(config, middleware, undefined)?.title).toBe('Dashboard | My Site');
});

it('middleware titleTemplate wins over config', () => {
const config: HeadConfig = { titleTemplate: '%s | My Site' };
const middleware: HeadConfig = { titleTemplate: '%s | Admin' };
const page: HeadDescriptor = { title: 'Users' };
expect(resolveHead(config, middleware, page)?.title).toBe('Users | Admin');
});

it('applies middleware titleTemplate without a config template', () => {
const middleware: HeadConfig = { titleTemplate: '%s | Admin' };
const page: HeadDescriptor = { title: 'Users' };
expect(resolveHead(undefined, middleware, page)?.title).toBe('Users | Admin');
});

it('falls back to config titleTemplate when middleware sets none', () => {
const config: HeadConfig = { titleTemplate: '%s | My Site' };
const middleware: HeadConfig = { meta: [{ name: 'robots', content: 'noindex' }] };
const page: HeadDescriptor = { title: 'Users' };
expect(resolveHead(config, middleware, page)?.title).toBe('Users | My Site');
});

it('does not emit titleTemplate in the resolved head', () => {
const middleware: HeadConfig = { titleTemplate: '%s | Admin' };
const page: HeadDescriptor = { title: 'Users' };
expect(resolveHead(undefined, middleware, page)).toEqual({ title: 'Users | Admin' });
});

it('deduplicates meta by name — page wins', () => {
const config: HeadConfig = {
meta: [{ name: 'description', content: 'Config desc' }],
Expand Down
27 changes: 27 additions & 0 deletions packages/void/test/unit/routing-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,33 @@ describe('typed Pages and SSR execution', () => {
title: 'loaded',
});
});
it('wraps the page title with a titleTemplate from middleware headDefaults', async () => {
const app = new Hono();
app.use(
'/admin/*',
defineMiddleware(async (c, next) => {
c.set('headDefaults', { ...c.get('headDefaults'), titleTemplate: '%s | Admin' });
await next();
}),
);
const options = {
...pageOptions,
headConfig: { titleTemplate: '%s | My Site' },
load: async () => ({ loader: () => ({}), head: () => ({ title: 'Users' }) }),
};
app.get('/admin/users', createPageGetHandler(options));
app.get('/users', createPageGetHandler(options));
const title = async (path: string) => {
const response = await app.fetch(
new Request('https://app.test' + path, {
headers: { 'x-voidpages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION },
}),
);
return (decodePageData(await response.json()) as { head?: { title?: string } }).head?.title;
};
expect(await title('/admin/users')).toBe('Users | Admin');
expect(await title('/users')).toBe('Users | My Site');
});
it('redirects extensionless Pages requests to trailing slashes without changing APIs or files', async () => {
const app = new Hono();
const routing = createRoutingRuntime(app, { nodeTarget: true });
Expand Down