Skip to content

Commit

Permalink
feat(core): allow creation of static imports from a transform function
Browse files Browse the repository at this point in the history
Export createDefaultImport and createNamedImport functions
to create static imports from within a transform function.
  • Loading branch information
sdorra committed Nov 23, 2024
1 parent ece98ba commit d208994
Show file tree
Hide file tree
Showing 8 changed files with 280 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .changeset/healthy-ducks-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@content-collections/core": minor
---

Allow creation of static imports from a transform function
18 changes: 17 additions & 1 deletion packages/core/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ZodObject, ZodRawShape, ZodString, ZodTypeAny, z } from "zod";
import { CacheFn } from "./cache";
import { GetTypeOfImport, Import } from "./import";
import { Parser, Parsers } from "./parser";
import { NotSerializableError, Serializable } from "./serializer";
import { generateTypeName } from "./utils";
Expand Down Expand Up @@ -111,6 +112,21 @@ type InvalidReturnType<TMessage extends string, TObject> = {
object: TObject;
};

type ResolveImports<TTransformResult> =
TTransformResult extends Import<any>
? GetTypeOfImport<TTransformResult>
: TTransformResult extends Array<infer U>
? Array<ResolveImports<U>>
: TTransformResult extends (...args: any[]) => any
? TTransformResult
: TTransformResult extends object
? {
[K in keyof TTransformResult]: ResolveImports<
TTransformResult[K]
>;
}
: TTransformResult;

export function defineCollection<
TName extends string,
TShape extends ZodRawShape,
Expand All @@ -121,7 +137,7 @@ export function defineCollection<
? Schema<TParser, TShape>
: Awaited<TTransformResult>,
TResult = TDocument extends Serializable
? Collection<TName, TShape, TParser, TSchema, TTransformResult, TDocument>
? Collection<TName, TShape, TParser, TSchema, TTransformResult, ResolveImports<TDocument>>
: InvalidReturnType<NotSerializableError, TDocument>,
>(
collection: CollectionRequest<
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const importSymbol = Symbol("import");

export type Import<T> = {
[importSymbol]: true;
path: string;
name?: string;
};

export type GetTypeOfImport<T> = T extends Import<infer U> ? U : never;

export function isImport(value: any): value is Import<any> {
return value && value[importSymbol];
}

export function createDefaultImport<T>(path: string): Import<T> {
return {
[importSymbol]: true,
path,
};
}

export function createNamedImport<T>(name: string, path: string): Import<T> {
return {
[importSymbol]: true,
path,
name,
};
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export type { Document, GetTypeByName, Modification } from "./types";

export { CollectError } from "./collector";
export { ConfigurationError } from "./configurationReader";
export { createDefaultImport, createNamedImport } from "./import";
export { TransformError } from "./transformer";
export { type Watcher } from "./watcher";
76 changes: 76 additions & 0 deletions packages/core/src/serializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { tmpdirTest } from "./__tests__/tmpdir";
import { createDefaultImport, createNamedImport } from "./import";
import { extension, serializableSchema, serialize } from "./serializer";

describe("serializer", () => {
Expand Down Expand Up @@ -149,5 +150,80 @@ describe("serializer", () => {
expect(imported[0].bigint).toBeTypeOf("bigint");
},
);

tmpdirTest(
"should serialize an object with an import",
async ({ tmpdir }) => {
const object = {
name: "sample",
fso: createDefaultImport("node:fs/promises"),
};

const imported = await writeAndImport(tmpdir, object);
expect(imported).toEqual([{ name: "sample", fso: fs }]);
},
);

tmpdirTest(
"should serialize an object with an a nested import",
async ({ tmpdir }) => {
const object = {
name: "sample",
imports: {
fso: createDefaultImport("node:fs/promises"),
},
};

const [imported] = await writeAndImport(tmpdir, object);
expect(imported.imports.fso).toEqual(fs);
},
);

tmpdirTest(
"should serialize an object with multiple imports",
async ({ tmpdir }) => {
const object = {
name: "sample",
imports: {
fs: createDefaultImport("node:fs/promises"),
path: createDefaultImport("node:path"),
},
};

const [imported] = await writeAndImport(tmpdir, object);
expect(imported.imports.fs).toEqual(fs);
expect(imported.imports.path).toEqual(path);
},
);

tmpdirTest(
"should serialize an object with an array of imports",
async ({ tmpdir }) => {
const object = {
name: "sample",
imports: [
createDefaultImport("node:fs/promises"),
createDefaultImport("node:path"),
],
};

const [imported] = await writeAndImport(tmpdir, object);
expect(imported.imports[0]).toEqual(fs);
expect(imported.imports[1]).toEqual(path);
},
);

tmpdirTest(
"should serialize an object with an name import",
async ({ tmpdir }) => {
const object = {
name: "sample",
join: createNamedImport("join", "node:path"),
};

const [imported] = await writeAndImport(tmpdir, object);
expect(imported.join).toEqual(path.join);
},
);
});
});
40 changes: 38 additions & 2 deletions packages/core/src/serializer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import serializeJs from "serialize-javascript";
import z from "zod";
import { Import, isImport } from "./import";

const literalSchema = z.union([
// json
Expand Down Expand Up @@ -35,11 +36,46 @@ export const serializableSchema = z.record(schema);

export type Serializable = z.infer<typeof serializableSchema>;

function createImport(imp: Import<unknown>, variableName: string): string {
const variableDeclaration = imp.name
? `{ ${imp.name} as ${variableName} }`
: variableName;

return `import ${variableDeclaration} from "${imp.path}";\n`;
}

export function serialize(value: Array<unknown>): string {
const serializedValue = serializeJs(value, {
let serializedValue = "";
let counter = 0;

function handleImports(item: any) {
if (item instanceof Object) {
Object.entries(item).forEach(([key, value]) => {
if (isImport(value)) {
counter++;
const variableName = `__v_${counter}`;
serializedValue += createImport(value, variableName);
item[key] = variableName;
} else if (value instanceof Object) {
handleImports(value);
}
});
}
}

value.forEach(handleImports);

serializedValue += "\n";

const js = serializeJs(value, {
space: 2,
unsafe: true,
ignoreFunction: true,
}).replace(/"__v_(\d+)"/g, (_, index) => {
return `__v_${index}`;
});
return `export default ${serializedValue};`;

serializedValue += "export default " + js;

return serializedValue;
}
114 changes: 113 additions & 1 deletion packages/core/src/types.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { defineCollection, defineConfig } from "./config";
import { createDefaultImport } from "./import";
import { GetTypeByName } from "./types";

describe("types", () => {
Expand Down Expand Up @@ -324,11 +325,11 @@ describe("types", () => {
directory: "./persons",
include: "*.md",
schema: (z) => ({
// date is not a valid json
date: z.string(),
}),
transform: (data) => {
return {
// functions are not serial
fn: () => {},
};
},
Expand All @@ -337,4 +338,115 @@ describe("types", () => {
// @ts-expect-error content is not a valid json object
expect(collection.name).toBeDefined();
});

it("should return the generic of the import", () => {
type Content = {
mdx: string;
};

const collection = defineCollection({
name: "posts",
directory: "./posts",
include: "*.mdx",
schema: (z) => ({
title: z.string(),
}),
transform: ({ _meta, ...rest }) => {
const content = createDefaultImport<Content>("./content");
return {
...rest,
content,
};
},
});

const config = defineConfig({
collections: [collection],
});

type Post = GetTypeByName<typeof config, "posts">;

const post: Post = {
title: "Hello World",
content: {
mdx: "# MDX Content",
},
};

expect(post).toBeTruthy();
});

it("should return the generic of a nested import", () => {
type Content = {
mdx: string;
};

const collection = defineCollection({
name: "posts",
directory: "./posts",
include: "*.mdx",
schema: (z) => ({
title: z.string(),
}),
transform: ({ _meta, content: _, ...rest }) => {
const content = createDefaultImport<Content>("./content");
return {
...rest,
props: {
content,
},
};
},
});

const config = defineConfig({
collections: [collection],
});

type Post = GetTypeByName<typeof config, "posts">;

const post: Post = {
title: "Hello World",
props: {
content: {
mdx: "# MDX Content",
},
}
};

expect(post).toBeTruthy();
});

it("should return a function from a resolve import", () => {
type Content = () => string;

const collection = defineCollection({
name: "posts",
directory: "./posts",
include: "*.mdx",
schema: (z) => ({
title: z.string(),
}),
transform: ({ _meta, ...rest }) => {
const content = createDefaultImport<Content>("./content");
return {
...rest,
content,
};
},
});

const config = defineConfig({
collections: [collection],
});

type Post = GetTypeByName<typeof config, "posts">;

const post: Post = {
title: "Hello World",
content: () => "# MDX Content",
};

expect(post).toBeTruthy();
});
});
3 changes: 2 additions & 1 deletion packages/core/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@
"@content-collections/core": ["./src"]
}
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/__tests__/**/*"]
}

0 comments on commit d208994

Please sign in to comment.