From 15d5e389eefc894b3904c6cf839be55d86b702c4 Mon Sep 17 00:00:00 2001 From: karthic Date: Thu, 12 Nov 2026 10:47:14 +0530 Subject: [PATCH] Allow `titleTemplate` in middleware `headDefaults`, closes #796. Co-Authored-By: Claude Opus 5.5 --- docs/guide/pages-routing/head.md | 18 ++++++++++++- packages/void/src/pages/head.ts | 6 ++--- packages/void/src/pages/protocol.ts | 6 ++--- packages/void/src/runtime/handler.ts | 4 +-- packages/void/test/unit/pages-head.test.ts | 26 ++++++++++++++++++ .../void/test/unit/routing-runtime.test.ts | 27 +++++++++++++++++++ 6 files changed, 78 insertions(+), 9 deletions(-) diff --git a/docs/guide/pages-routing/head.md b/docs/guide/pages-routing/head.md index d53f44ef2..4b239a3de 100644 --- a/docs/guide/pages-routing/head.md +++ b/docs/guide/pages-routing/head.md @@ -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 `Users | Admin`. 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. | diff --git a/packages/void/src/pages/head.ts b/packages/void/src/pages/head.ts index edb9e301c..80c9f06d3 100644 --- a/packages/void/src/pages/head.ts +++ b/packages/void/src/pages/head.ts @@ -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) { @@ -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; } diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index 912df0681..b5a1c93cd 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -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 }); @@ -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 | 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); @@ -900,7 +900,7 @@ async function runPageMutation( errors: Record | null, ): PageObject { const shared = (c.get('shared') as Record | 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 { diff --git a/packages/void/src/runtime/handler.ts b/packages/void/src/runtime/handler.ts index e50662469..a9ed0f540 100644 --- a/packages/void/src/runtime/handler.ts +++ b/packages/void/src/runtime/handler.ts @@ -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'; @@ -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; } diff --git a/packages/void/test/unit/pages-head.test.ts b/packages/void/test/unit/pages-head.test.ts index 776ddcef5..d19ae88e1 100644 --- a/packages/void/test/unit/pages-head.test.ts +++ b/packages/void/test/unit/pages-head.test.ts @@ -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' }], diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 10d922081..e44a1f053 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -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 });