/** * Head resolution + merging, in its own module so the CLIENT can have it without the server. * * These two are pure functions over `Meta`, but they used to live in `index.ts` - the module that also * carries `renderPage`, `createWebApp` and the static-file server. The generated client entry imported * them from `@nifrajs/web`, which under a bundler was harmless (server code tree-shakes away) and under * Vite's dev server was fatal: it serves each module as-is, so pulling `index.ts` into the browser graph * executed `public-dir.ts` and the page died on `Module "node:fs/promises" has been externalized` * before hydrating. Splitting them out means the client entry never names the server module at all, * rather than relying on a bundler to remove it afterwards. * * `index.ts` and `client.ts` both re-export from here, so the public API is unchanged in both places. */ import type { LinkDescriptor, Meta, MetaArgs, MetaDescriptor, MetaInput, ScriptDescriptor, UnsafeScriptDescriptor, } from "../manifest.ts" // Server head serialization may memoize only objects that came exclusively from a static meta export. // A dynamic function is allowed to reuse and mutate one object, including an object another route also // exports statically, so identity alone does not prove cacheability. Once observed dynamically, an // object stays uncacheable: losing a small static optimization is safer than cross-request stale data. const STATIC_META = new WeakSet() const DYNAMIC_META = new WeakSet() /** @internal Whether `resolveMeta` observed this object as a static export. */ export function isStaticMeta(meta: Meta): boolean { return STATIC_META.has(meta) && !DYNAMIC_META.has(meta) } /** Resolve a route's `meta` export - a static object, or a function of the route's data/params. */ export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta { if (meta === undefined) return {} if (typeof meta === "function") { const resolved = meta(args) DYNAMIC_META.add(resolved) return resolved } STATIC_META.add(meta) return meta } /** * Merge a layout chain's heads, outermost first: `title`/`lang`/`dir` are nearest-wins, and * `meta`/`link`/`script` concatenate in chain order. * * Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a * static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is * fine: `headTags`'s memo is keyed on identity, so a per-request merge simply recomputes (its * content can vary with loader data anyway). */ export function mergeHeads(heads: readonly Meta[]): Meta { // Single-head fast path (a route with no layout `meta`, by far the common case) - return the // resolved object by reference so headTags' identity-keyed memo hits across requests for static meta. if (heads.length === 1) return heads[0] as Meta let title: string | undefined let lang: string | undefined let dir: Meta["dir"] const meta: MetaDescriptor[] = [] const link: LinkDescriptor[] = [] const script: ScriptDescriptor[] = [] const unsafeScript: UnsafeScriptDescriptor[] = [] for (const h of heads) { if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides if (h.lang !== undefined) lang = h.lang // nearest-wins, like title if (h.dir !== undefined) dir = h.dir if (h.meta !== undefined) meta.push(...h.meta) if (h.link !== undefined) link.push(...h.link) if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first) if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript) } // Build the result with only the fields that were actually contributed - an empty `meta`/`link`/ // `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta` // is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees // `undefined`). const merged: { title?: string meta?: Meta["meta"] link?: Meta["link"] script?: Meta["script"] unsafeScript?: Meta["unsafeScript"] lang?: string dir?: Meta["dir"] } = {} if (title !== undefined) merged.title = title if (meta.length > 0) merged.meta = meta if (link.length > 0) merged.link = link if (script.length > 0) merged.script = script if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript if (lang !== undefined) merged.lang = lang if (dir !== undefined) merged.dir = dir return merged as Meta }