/** * Zod schemas for the chat `MessageBlock` discriminated union (`./block.ts`). * * These are the runtime VALIDATORS used at the single registry dispatch point * (`messages/blocks/MessageBlocks.tsx`): every block flows through * `safeParseBlock(BLOCK_SCHEMAS[kind], block)` BEFORE its renderer runs, so a * malformed wire / hand-authored payload renders a safe `` card * instead of throwing inside a renderer and blanking the transcript. * * Design rules (mirroring `common/blocks/schemas.ts`): * - The TS types in `./block.ts` stay the source of truth; these schemas are * kept in sync and `Extends<…>`-checked below so they can't silently drift. * - The map / link shapes are COMPOSED from the shared * `MapBlockPayloadSchema` / `LinkPreviewDataSchema` in `common/blocks` — we * never re-describe a map/link payload here. * - LENIENT where the renderer is tolerant: optional fields stay optional, * `json.data` is `unknown` (JsonTree is throw-safe), extra keys are stripped. * - STRICT on the load-bearing shape: a field whose wrong type would THROW in * the renderer is required + typed (gallery `items` must be an array; * image/video/audio `src` must be a string; map coords are finite * numbers via the shared schema). * - `custom` and the prose kinds (`text`/`markdown`) carry no schema in * `BLOCK_SCHEMAS` — `custom` is host-defined (don't over-constrain) and the * prose kinds can't throw on a wrong-typed field worth guarding. They render * as-is. (`text`/`markdown` schemas are still defined for completeness.) */ import { z } from 'zod'; import { MapBlockPayloadSchema, LinkPreviewDataSchema } from '../../../common/blocks'; import type { TextBlock, MarkdownBlock, AudioBlock, VideoBlock, ImageBlock, GalleryBlock, MapBlock, JsonBlock, MermaidBlock, CodeBlock, DiffBlock, LinkBlock, MessageBlock, } from './block'; /** * Base fields shared by every block (`BlockBase` in `./block.ts`). We validate * the WHOLE block object (incl. `kind` / `id` / `caption`), so each schema * composes this. `id` is required (it is the React key + memo discriminant). */ const BlockBaseSchema = z.object({ id: z.string(), caption: z.string().optional(), }); const TextBlockSchema = BlockBaseSchema.extend({ kind: z.literal('text'), text: z.string(), }); const MarkdownBlockSchema = BlockBaseSchema.extend({ kind: z.literal('markdown'), markdown: z.string(), }); const AudioBlockSchema = BlockBaseSchema.extend({ kind: z.literal('audio'), src: z.string(), title: z.string().optional(), artist: z.string().optional(), cover: z.string().optional(), variant: z.enum(['default', 'compact']).optional(), }); const VideoBlockSchema = BlockBaseSchema.extend({ kind: z.literal('video'), src: z.string(), poster: z.string().optional(), title: z.string().optional(), aspectRatio: z.union([z.number(), z.literal('auto'), z.literal('fill')]).optional(), }); const ImageBlockSchema = BlockBaseSchema.extend({ kind: z.literal('image'), src: z.string(), alt: z.string().optional(), interactive: z.boolean().optional(), }); const GalleryItemSchema = z.object({ id: z.string(), src: z.string(), thumbnail: z.string().optional(), alt: z.string().optional(), }); const GalleryBlockSchema = BlockBaseSchema.extend({ kind: z.literal('gallery'), // STRICT: `items` must be an array — the renderer maps over it. items: z.array(GalleryItemSchema), }); /** * Map block = the shared serializable map payload (`MapBlockPayloadSchema`, * which already enforces finite-number `center.lat/lng` etc.) + the base block * fields + the chat-only `userLocation` opt-in flag. Composed, never * duplicated. */ const MapBlockSchema = MapBlockPayloadSchema.merge(BlockBaseSchema).extend({ kind: z.literal('map'), userLocation: z.boolean().optional(), }); const JsonBlockSchema = BlockBaseSchema.extend({ kind: z.literal('json'), // LENIENT: JsonTree renders anything safely — accept any payload. data: z.unknown(), mode: z.enum(['full', 'compact', 'inline']).optional(), }); const MermaidBlockSchema = BlockBaseSchema.extend({ kind: z.literal('mermaid'), chart: z.string(), }); const CodeBlockSchema = BlockBaseSchema.extend({ kind: z.literal('code'), code: z.string(), language: z.string(), }); const DiffBlockSchema = BlockBaseSchema.extend({ kind: z.literal('diff'), oldCode: z.string().optional(), newCode: z.string().optional(), patch: z.string().optional(), language: z.string().optional(), layout: z.enum(['split', 'unified']).optional(), }); /** * Link block = base fields + `url` + optional pre-resolved `data` (validated * against the shared `LinkPreviewDataSchema`). The renderer also tolerates a * resolver-only block (no `data`), so `data` stays optional. */ const LinkBlockSchema = BlockBaseSchema.extend({ kind: z.literal('link'), url: z.string(), data: LinkPreviewDataSchema.optional(), }); // ── Drift guards ────────────────────────────────────────────────────────── // Type-level assertion that each schema's inferred output stays assignable to // the canonical block type in `./block.ts`. If a schema and the type diverge, // the corresponding assignment fails to compile. type Extends = A extends B ? true : never; const _textOk: Extends, TextBlock> = true; const _markdownOk: Extends, MarkdownBlock> = true; const _audioOk: Extends, AudioBlock> = true; const _videoOk: Extends, VideoBlock> = true; const _imageOk: Extends, ImageBlock> = true; const _galleryOk: Extends, GalleryBlock> = true; const _mapOk: Extends, MapBlock> = true; const _jsonOk: Extends, JsonBlock> = true; const _mermaidOk: Extends, MermaidBlock> = true; const _codeOk: Extends, CodeBlock> = true; const _diffOk: Extends, DiffBlock> = true; const _linkOk: Extends, LinkBlock> = true; void [ _textOk, _markdownOk, _audioOk, _videoOk, _imageOk, _galleryOk, _mapOk, _jsonOk, _mermaidOk, _codeOk, _diffOk, _linkOk, ]; /** * `kind` → schema. Validated at the registry dispatch point. Kinds WITHOUT an * entry are skipped (rendered as-is): * - `custom` — host-defined `payload`; over-constraining it would break hosts. * * Every built-in data block is covered so a malformed payload is caught * centrally — and any FUTURE kind is guarded the moment it's added here. */ export const BLOCK_SCHEMAS: Partial> = { text: TextBlockSchema, markdown: MarkdownBlockSchema, audio: AudioBlockSchema, video: VideoBlockSchema, image: ImageBlockSchema, gallery: GalleryBlockSchema, map: MapBlockSchema, json: JsonBlockSchema, mermaid: MermaidBlockSchema, code: CodeBlockSchema, diff: DiffBlockSchema, link: LinkBlockSchema, // `custom` intentionally omitted — host-defined, rendered as-is. };