diff --git a/.changeset/fluffy-poets-invite.md b/.changeset/fluffy-poets-invite.md new file mode 100644 index 00000000..45505309 --- /dev/null +++ b/.changeset/fluffy-poets-invite.md @@ -0,0 +1,5 @@ +--- +"@content-collections/core": minor +--- + +Validate the resulting objects and ensure that they can be serialized to json diff --git a/integrations/cli/content-collections.ts b/integrations/cli/content-collections.ts index 9e4d0948..845962a4 100644 --- a/integrations/cli/content-collections.ts +++ b/integrations/cli/content-collections.ts @@ -17,9 +17,7 @@ const posts = defineCollection({ schema: (z) => ({ title: z.string().min(5), description: z.string().min(10), - date: z - .union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.date()]) - .transform((val) => new Date(val)), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), author: z.string(), }), directory: "posts", diff --git a/integrations/cli/simple.test.ts b/integrations/cli/simple.test.ts index 1deb67ca..3a199829 100644 --- a/integrations/cli/simple.test.ts +++ b/integrations/cli/simple.test.ts @@ -14,7 +14,7 @@ describe("simple", () => { expect(post).toEqual({ title: "Post One", description: "This is the first post", - date: "2019-01-01T00:00:00.000Z", + date: "2019-01-01", author: { displayName: "Tricia Marie McMillan", email: "trillian@hitchhiker.com", @@ -40,7 +40,7 @@ describe("simple", () => { expect(post).toEqual({ title: "Post Two", description: "This is the second post", - date: "2020-01-01T00:00:00.000Z", + date: "2020-01-01", author: { displayName: "Tricia Marie McMillan", email: "trillian@hitchhiker.com", diff --git a/packages/core/src/__tests__/collections/posts.ts b/packages/core/src/__tests__/collections/posts.ts index 752ac03c..3d05db76 100644 --- a/packages/core/src/__tests__/collections/posts.ts +++ b/packages/core/src/__tests__/collections/posts.ts @@ -1,15 +1,20 @@ import { defineCollection } from "@content-collections/core"; -export default defineCollection({ +const collection = defineCollection({ name: "posts", typeName: "Post", schema: (z) => ({ title: z.string().min(5), description: z.string().min(10), - date: z - .union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.date()]) - .transform((val) => new Date(val)), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), }), + transform: (doc) => { + return { + ...doc, + }; + }, directory: "posts", include: "**/*.md(x)?", }); + +export default collection; \ No newline at end of file diff --git a/packages/core/src/__tests__/config.001.ts b/packages/core/src/__tests__/config.001.ts index ef3e77dc..03a5aef2 100644 --- a/packages/core/src/__tests__/config.001.ts +++ b/packages/core/src/__tests__/config.001.ts @@ -6,9 +6,7 @@ const posts = defineCollection({ schema: (z) => ({ title: z.string().min(5), description: z.string().min(10), - date: z - .union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.date()]) - .transform((val) => new Date(val)), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), }), directory: "sources/posts", include: "**/*.md(x)?", diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index f6e326f2..0fa93cc8 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -2,6 +2,7 @@ import { ZodObject, ZodRawShape, ZodString, ZodTypeAny, z } from "zod"; import { generateTypeName } from "./utils"; import { Parser, Parsers } from "./parser"; import { CacheFn } from "./cache"; +import { JSONObject } from "./json"; export type Meta = { filePath: string; @@ -92,6 +93,16 @@ export type Collection< export type AnyCollection = Collection; +type NonJSONObjectError = + "The return type of the transform function must be an valid JSONObject, the following type is not valid:"; + +const InvalidReturnTypeSymbol = Symbol(`InvalidReturnType`); + +type InvalidReturnType = { + [InvalidReturnTypeSymbol]: TMessage; + object: TObject; +}; + export function defineCollection< TName extends string, TShape extends ZodRawShape, @@ -101,6 +112,9 @@ export function defineCollection< TDocument = [TTransformResult] extends [never] ? Schema : Awaited, + TResult = TDocument extends JSONObject + ? Collection + : InvalidReturnType, >( collection: CollectionRequest< TName, @@ -110,7 +124,7 @@ export function defineCollection< TTransformResult, TDocument > -): Collection { +): TResult { let typeName = collection.typeName; if (!typeName) { typeName = generateTypeName(collection.name); @@ -124,7 +138,7 @@ export function defineCollection< typeName, parser, schema: collection.schema(z), - }; + } as TResult; } type Cache = "memory" | "file" | "none"; diff --git a/packages/core/src/json.test.ts b/packages/core/src/json.test.ts new file mode 100644 index 00000000..9a52f139 --- /dev/null +++ b/packages/core/src/json.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { jsonObjectScheme } from "./json"; + +describe("json", () => { + it("should pass valid json", () => { + const json = { + a: 1, + b: "string", + c: true, + d: null, + e: { + f: "nested", + }, + g: [1, 2, 3], + }; + + const result = jsonObjectScheme.safeParse(json); + expect(result.success).toBe(true); + }); + + it("should allow undefined values", () => { + const json = { + a: undefined, + }; + + const result = jsonObjectScheme.safeParse(json); + expect(result.success).toBe(true); + }); + + it("should fail if object contains a date object", () => { + const json = { + a: new Date(), + }; + + const result = jsonObjectScheme.safeParse(json); + expect(result.success).toBe(false); + }); + + it("should fail if object contains a function", () => { + const json = { + a: () => {}, + }; + + const result = jsonObjectScheme.safeParse(json); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/core/src/json.ts b/packages/core/src/json.ts new file mode 100644 index 00000000..03f5779a --- /dev/null +++ b/packages/core/src/json.ts @@ -0,0 +1,22 @@ +import z from "zod"; + +const literalSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.undefined(), +]); + +type Literal = z.infer; + +type Json = Literal | { [key: string]: Json } | Json[]; + + +const jsonSchema: z.ZodType = z.lazy(() => + z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]) +); + +export const jsonObjectScheme = z.record(jsonSchema); + +export type JSONObject = z.infer; \ No newline at end of file diff --git a/packages/core/src/transformer.test.ts b/packages/core/src/transformer.test.ts index 3a4fa820..1b1c8d7b 100644 --- a/packages/core/src/transformer.test.ts +++ b/packages/core/src/transformer.test.ts @@ -70,6 +70,13 @@ const firstPost: CollectionFile = { path: "first.md", }; +const invalidPost: CollectionFile = { + data: { + date: new Date(), + }, + path: "first.md", +}; + const authorTrillian: CollectionFile = { data: { ref: "trillian", @@ -535,4 +542,63 @@ describe("transform", () => { ]); expect(collection?.documents).toHaveLength(0); }); + + it("should report an result error, if the transform result is not a valid JSON object", async () => { + const posts = defineCollection({ + name: "posts", + schema: (z) => ({ + title: z.string(), + }), + transform: (doc) => { + return { + ...doc, + date: new Date(), + }; + }, + directory: "tests", + include: "*.md" + }); + + const errors: Array = []; + emitter.on("transformer:result-error", (event) => errors.push(event.error)); + + await createTransformer( + emitter, + noopCacheManager + )([ + // @ts-expect-error posts is invalid + { + ...posts, + files: [firstPost], + }, + ]); + expect(errors[0]?.type).toBe("Result"); + }); + + it("should report an result error, if the schema result is not a valid JSON object", async () => { + const posts = defineCollection({ + name: "posts", + parser: "json", + schema: (z) => ({ + date: z.date(), + }), + directory: "tests", + include: "*.md" + }); + + const errors: Array = []; + emitter.on("transformer:result-error", (event) => errors.push(event.error)); + + await createTransformer( + emitter, + noopCacheManager + )([ + // @ts-expect-error posts is invalid + { + ...posts, + files: [invalidPost], + }, + ]); + expect(errors[0]?.type).toBe("Result"); + }); }); diff --git a/packages/core/src/transformer.ts b/packages/core/src/transformer.ts index 9a1a5deb..b7c1e080 100644 --- a/packages/core/src/transformer.ts +++ b/packages/core/src/transformer.ts @@ -6,6 +6,7 @@ import { basename, dirname, extname } from "node:path"; import { z } from "zod"; import { Parser, parsers } from "./parser"; import { CacheManager, Cache } from "./cache"; +import { jsonObjectScheme } from "./json"; export type TransformerEvents = { "transformer:validation-error": { @@ -13,6 +14,11 @@ export type TransformerEvents = { file: CollectionFile; error: TransformError; }; + "transformer:result-error": { + collection: AnyCollection; + document: any; + error: TransformError; + }; "transformer:error": { collection: AnyCollection; error: TransformError; @@ -31,7 +37,7 @@ export type TransformedCollection = AnyCollection & { documents: Array; }; -export type ErrorType = "Validation" | "Configuration" | "Transform"; +export type ErrorType = "Validation" | "Configuration" | "Transform" | "Result"; export class TransformError extends Error { type: ErrorType; @@ -143,12 +149,16 @@ export function createTransformer( if (collection.transform) { const docs = []; for (const doc of collection.documents) { - const cache = cacheManager.cache(collection.name, doc.document._meta.path); + const cache = cacheManager.cache( + collection.name, + doc.document._meta.path + ); const context = createContext(collections, cache); try { + const document = await collection.transform(doc.document, context); docs.push({ ...doc, - document: await collection.transform(doc.document, context), + document, }); await cache.tidyUp(); } catch (error) { @@ -168,9 +178,27 @@ export function createTransformer( await cacheManager.flush(); return docs; } + return collection.documents; } + async function validateDocuments(collection: AnyCollection, documents: Array) { + const docs = []; + for (const doc of documents) { + let parsedData = await jsonObjectScheme.safeParseAsync(doc.document); + if (parsedData.success) { + docs.push(doc); + } else { + emitter.emit("transformer:result-error", { + collection, + document: doc.document, + error: new TransformError("Result", parsedData.error.message), + }); + } + } + return docs; + } + return async (untransformedCollections: Array) => { const promises = untransformedCollections.map((collection) => parseCollection(collection) @@ -178,7 +206,8 @@ export function createTransformer( const collections = await Promise.all(promises); for (const collection of collections) { - collection.documents = await transformCollection(collections, collection); + const documents = await transformCollection(collections, collection); + collection.documents = await validateDocuments(collection, documents); } return collections; diff --git a/packages/core/src/types.test.ts b/packages/core/src/types.test.ts index 161257da..ab255d1c 100644 --- a/packages/core/src/types.test.ts +++ b/packages/core/src/types.test.ts @@ -114,7 +114,7 @@ describe("types", () => { country: { code: country.code, name: country.name, - } + }, }; }, }); @@ -302,4 +302,39 @@ describe("types", () => { expect(person).toBeTruthy(); }); + + it("should return invalid type if returned schema is not a valid json object", () => { + const collection = defineCollection({ + name: "person", + directory: "./persons", + include: "*.md", + schema: (z) => ({ + // date is not a valid json + date: z.date(), + }), + }); + + // @ts-expect-error content is not a valid json object + expect(collection.name).toBeDefined(); + }); + + it("should return invalid type if returned transform is not a valid json object", () => { + const collection = defineCollection({ + name: "person", + directory: "./persons", + include: "*.md", + schema: (z) => ({ + // date is not a valid json + date: z.string(), + }), + transform: (data) => { + return { + date: new Date(data.date), + }; + }, + }); + + // @ts-expect-error content is not a valid json object + expect(collection.name).toBeDefined(); + }); }); diff --git a/samples/mdx/README.md b/samples/mdx/README.md index 8b2d0d6d..91a00b51 100644 --- a/samples/mdx/README.md +++ b/samples/mdx/README.md @@ -26,8 +26,7 @@ const posts = defineCollection({ directory: "content", include: "*.mdx", schema: (z) => ({ - title: z.string(), - date: z.date(), + title: z.string() }), transform: async (document, context) => { const body = await compileMDX(context, document);