/** * Astro component references can't be JSON-serialized, so when a story passes an * Astro component as a prop or as slot content, the client replaces it with this * plain marker before sending the render request. The server reconstructs it: a * marker used as a prop is resolved back to the real component factory (the * parent template renders it via ``); a marker used as slot content is * rendered to an HTML string (the Astro Container only accepts string slots). * * Only the `moduleId` needs to cross the boundary — the server loads the * component from it exactly like it loads the top-level story component. * * Scope: a marker carries a bare component reference, not per-instance props or * slots. Passing a "configured" child (with its own props/slots) or a non-Astro * framework component as a slot/prop is not supported yet. */ export const ASTRO_COMPONENT_MARKER = '__astroComponent'; export type AstroComponentMarker = { [ASTRO_COMPONENT_MARKER]: true; moduleId: string; }; /** * A value is a marker only when both keys are present and well-typed, so a * legitimate plain-object arg can't be mistaken for one. */ export function isAstroComponentMarker(value: unknown): value is AstroComponentMarker { return ( typeof value === 'object' && value !== null && (value as Record)[ASTRO_COMPONENT_MARKER] === true && typeof (value as Record).moduleId === 'string' ); } /** * Detects an Astro component factory — the callable produced by importing a * `.astro` file. On the client this is the stub from `vitePluginAstroComponentMarker` * (which sets `isAstroComponentFactory` and `moduleId`); on the server (and in the * Vitest/portable path) it's the real Astro factory, which also sets the flag. */ export function isAstroComponentFactory(value: unknown): boolean { return ( typeof value === 'function' && 'isAstroComponentFactory' in value && (value as { isAstroComponentFactory?: unknown }).isAstroComponentFactory === true ); } /** * Recursively replaces Astro component factories in an args/slots value with * serializable {@link AstroComponentMarker}s so the value can cross the JSON * render boundary. Walks arrays and nested objects. A factory missing its * `moduleId` can't be rendered server-side, so it's dropped with an error. */ export function serializeAstroComponentMarkers(value: unknown, depth = 0): unknown { if (isAstroComponentFactory(value)) { const moduleId = (value as { moduleId?: unknown }).moduleId; if (typeof moduleId !== 'string') { console.error( '[storybook-astro] An Astro component passed in args has no moduleId and cannot be rendered.' ); return undefined; } return { [ASTRO_COMPONENT_MARKER]: true, moduleId } satisfies AstroComponentMarker; } if (depth >= 10) { return value; } if (Array.isArray(value)) { return value.map((item) => serializeAstroComponentMarkers(item, depth + 1)); } // Only recurse into plain objects. Other object types (Date, RegExp, Map, // class instances, …) can't hold an Astro factory and must pass through // untouched — walking them with Object.entries would clobber a Date into an // empty object, losing it when the args are JSON-serialized for transport. if (isPlainObject(value)) { const out: Record = {}; for (const [key, nested] of Object.entries(value)) { out[key] = serializeAstroComponentMarkers(nested, depth + 1); } return out; } return value; } function isPlainObject(value: unknown): value is Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false; } const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; }