/** * Built-in Meta handle for managing document metadata across route segments. * * Provides automatic deduplication: later routes override earlier ones * for the same meta key (title, name, property, etc.) * * @example * ```tsx * // In route handler * route("product/:id", (ctx) => { * const meta = ctx.use(Meta); * meta({ title: "Product Details" }); * meta({ name: "description", content: "..." }); * meta({ property: "og:title", content: "..." }); * }); * * // In layout (renders the collected meta tags) * function RootLayout() { * return ( * * * * * ... * * ); * } * ``` */ import { createHandle, type Handle } from "../handle.js"; import type { MetaDescriptor, TitleDescriptor, UnsetDescriptor, } from "../router/types.js"; /** * Type guard for unset descriptor */ function isUnsetDescriptor( descriptor: MetaDescriptor, ): descriptor is UnsetDescriptor { return ( typeof descriptor === "object" && descriptor !== null && "unset" in descriptor && typeof (descriptor as UnsetDescriptor).unset === "string" ); } /** * Type guard for title descriptor (any form) */ function isTitleDescriptor( descriptor: MetaDescriptor, ): descriptor is { title: TitleDescriptor } { return ( typeof descriptor === "object" && descriptor !== null && "title" in descriptor ); } /** * Type guard for title template descriptor */ function isTitleTemplate( title: TitleDescriptor, ): title is { template: string; default: string } { return ( typeof title === "object" && title !== null && "template" in title && "default" in title ); } /** * Type guard for absolute title descriptor */ function isAbsoluteTitle( title: TitleDescriptor, ): title is { absolute: string } { return typeof title === "object" && title !== null && "absolute" in title; } /** * Get a unique key for a meta descriptor for deduplication. * Returns undefined for descriptors that shouldn't be deduplicated. */ function getMetaKey(descriptor: MetaDescriptor): string | undefined { // Skip unset descriptors - they are processed separately if (isUnsetDescriptor(descriptor)) { return undefined; } if ("charSet" in descriptor) { return "charSet"; } if ("title" in descriptor) { return "title"; } if ("name" in descriptor && "content" in descriptor) { return `name:${descriptor.name}`; } if ("property" in descriptor && "content" in descriptor) { return `property:${descriptor.property}`; } if ("httpEquiv" in descriptor && "content" in descriptor) { return `httpEquiv:${descriptor.httpEquiv}`; } if ("script:ld+json" in descriptor) { // JSON-LD scripts can have multiple, don't dedupe by default return undefined; } if ("tagName" in descriptor) { // For link tags, dedupe by rel if present if (descriptor.tagName === "link" && "rel" in descriptor) { // Some link rels should be unique (canonical), others not (stylesheet) const uniqueRels = ["canonical", "icon", "apple-touch-icon"]; if (uniqueRels.includes(descriptor.rel as string)) { return `link:${descriptor.rel}`; } } return undefined; } return undefined; } /** * Default meta descriptors included automatically. * These can be overridden by route handlers. */ const defaultMetaDescriptors: MetaDescriptor[] = [ { charSet: "utf-8" }, { name: "viewport", content: "width=device-width, initial-scale=1" }, ]; /** * Helper to add or replace a descriptor in the result array */ function addOrReplace( result: MetaDescriptor[], keyToIndex: Map, descriptor: MetaDescriptor, key: string | undefined, ): void { if (key !== undefined && keyToIndex.has(key)) { result[keyToIndex.get(key)!] = descriptor; } else { if (key !== undefined) { keyToIndex.set(key, result.length); } result.push(descriptor); } } /** * Helper to update indices after removing an element */ function updateIndicesAfterRemoval( keyToIndex: Map, removedIndex: number, ): void { for (const [key, index] of keyToIndex) { if (index > removedIndex) { keyToIndex.set(key, index - 1); } } } /** * Collect function for Meta handle. * Includes default meta descriptors, then deduplicates by key with later routes overriding earlier ones. * Supports title templates, absolute titles, and unset descriptors. */ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] { const result: MetaDescriptor[] = []; const keyToIndex = new Map(); let titleTemplate: string | undefined; // Add defaults first so they can be overridden for (const descriptor of defaultMetaDescriptors) { const key = getMetaKey(descriptor); if (key !== undefined) { keyToIndex.set(key, result.length); } result.push(descriptor); } for (const descriptors of segments) { for (const descriptor of descriptors) { // Handle unset descriptors if (isUnsetDescriptor(descriptor)) { const keyToRemove = descriptor.unset; if (keyToIndex.has(keyToRemove)) { const idx = keyToIndex.get(keyToRemove)!; result.splice(idx, 1); keyToIndex.delete(keyToRemove); updateIndicesAfterRemoval(keyToIndex, idx); } continue; } // Handle title descriptors with template/absolute support if (isTitleDescriptor(descriptor)) { const titleValue = descriptor.title; if (isTitleTemplate(titleValue)) { // Store template for subsequent title descriptors in child segments titleTemplate = titleValue.template; // Set the default title addOrReplace( result, keyToIndex, { title: titleValue.default }, "title", ); continue; } if (isAbsoluteTitle(titleValue)) { // Absolute title bypasses any template addOrReplace( result, keyToIndex, { title: titleValue.absolute }, "title", ); continue; } // String title - apply template if one exists const finalTitle = titleTemplate ? titleTemplate.replace("%s", titleValue as string) : titleValue; addOrReplace( result, keyToIndex, { title: finalTitle as string }, "title", ); continue; } // Handle all other descriptors const key = getMetaKey(descriptor); addOrReplace(result, keyToIndex, descriptor, key); } } return result; } /** * Built-in handle for managing document metadata. * * Use `ctx.use(Meta)` in route handlers to push meta descriptors. * Use `` component to render them in the document head. */ export const Meta: Handle = createHandle< MetaDescriptor, MetaDescriptor[] >(collectMeta, "__rsc_router_meta__");