diff --git a/packages/groqd/src/examples.test.ts b/packages/groqd/src/examples.test.ts new file mode 100644 index 00000000..b365f0be --- /dev/null +++ b/packages/groqd/src/examples.test.ts @@ -0,0 +1,43 @@ +import { describe, expectTypeOf, it } from "vitest"; +import { q } from "./tests/schemas/nextjs-sanity-fe"; +import { InferResultItem } from "./types/public-types"; +import { SanityContentBlocks } from "./validation/content-blocks"; + +describe("example queries", () => { + describe("portable text", () => { + // This is the type for ContentBlocks generated from Sanity: + + // There are various ways to query the content: + + it("can be queried without client-side validation", () => { + const qText = q.star.filterByType("product").project((sub) => ({ + description: sub.field("description[]"), + })); + expectTypeOf>().toMatchTypeOf<{ + description: null | SanityContentBlocks; + }>(); + }); + it("can be queried via q.contentBlocks()", () => { + const qText = q.star.filterByType("product").project((sub) => ({ + description: sub.field("description[]", q.contentBlocks().nullable()), + })); + expectTypeOf< + InferResultItem["description"] + >().toEqualTypeOf(); + }); + it("can be queried conditionally based on type", () => { + const qText = q.star.filterByType("product").project((sub) => ({ + description: sub.field("description[]").project((desc) => ({ + ...desc.conditionalByType({ + block: { + "...": true, + }, + }), + })), + })); + expectTypeOf< + InferResultItem["description"] + >().toMatchTypeOf(); + }); + }); +}); diff --git a/packages/groqd/src/validation/content-blocks.ts b/packages/groqd/src/validation/content-blocks.ts new file mode 100644 index 00000000..7ad70bd8 --- /dev/null +++ b/packages/groqd/src/validation/content-blocks.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +/** + * Represents an array of blocks of Sanity's portable text. + */ +export type SanityContentBlocks = + Array>; + +/** + * Represents a block of Sanity's portable text, for fields defined as `type: "block"`. + * This rich text type can be expanded via custom `markDefs`. + */ +export type SanityContentBlock = + { + _type: "block"; + _key: string; + level?: number; + style?: string; // eg. "normal" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote"; + listItem?: string; // eg. "bullet" | "number"; + markDefs?: Array; + children?: Array<{ + _type: string; // eg. "span"; + _key: string; + text?: string; + marks?: Array; + }>; + }; + +export type MarkDefsBase = { + _type: string; // eg. "link"; + _key: string; +}; + +const markDefBase = z + .object({ + _type: z.string(), + _key: z.string(), + }) + .catchall(z.unknown()) satisfies z.ZodType; + +export type ContentBlockOverrides = { + /** + * Supply your own custom markDef definitions. + * + * @example + * markDefs: z.object({ + * _type: z.literal("link"), + * _key: z.string(), + * href: z.string().optional(), + * }) + */ + markDefs?: z.ZodType; +}; +export function contentBlocks( + overrides?: ContentBlockOverrides +) { + return z.array(contentBlock(overrides)); +} +export function contentBlock({ + markDefs = markDefBase as z.ZodType, +}: ContentBlockOverrides = {}): z.ZodType< + SanityContentBlock +> { + return z.object({ + _type: z.literal("block"), + _key: z.string(), + level: z.number().optional(), + style: z.string().optional(), + listItem: z.string().optional(), + markDefs: z.array(markDefs).optional(), + children: z + .array( + z.object({ + _type: z.string(), + _key: z.string(), + text: z.string().optional(), + marks: z.array(z.string()), + }) + ) + .optional(), + }); +} diff --git a/packages/groqd/src/validation/zod.ts b/packages/groqd/src/validation/zod.ts index 17f69667..bdcbc9c6 100644 --- a/packages/groqd/src/validation/zod.ts +++ b/packages/groqd/src/validation/zod.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { ParserFunction } from "../types/public-types"; import { pick } from "../types/utils"; +import { contentBlock, contentBlocks } from "./content-blocks"; const zodPrimitives = pick(z, [ "string", @@ -12,6 +13,8 @@ const zodPrimitives = pick(z, [ "union", "array", "object", + "any", + "enum", ]); const zodExtras = { @@ -54,6 +57,9 @@ const zodExtras = { slug(fieldName: TFieldName) { return [`${fieldName}.current`, z.string()] as const; }, + + contentBlock: contentBlock, + contentBlocks: contentBlocks, }; export const zodMethods = {