Skip to content

Commit

Permalink
refactor: swap to @effect/schema for SolutionPagesSchema
Browse files Browse the repository at this point in the history
  • Loading branch information
lishaduck committed Oct 18, 2024
1 parent 4087adb commit a835e04
Show file tree
Hide file tree
Showing 4 changed files with 93 additions and 81 deletions.
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"$fresh/": "https://deno.land/x/fresh@1.6.8/",
"$gfm": "https://deno.land/x/gfm@0.6.0/mod.ts",
"$tabler_icons/": "https://deno.land/x/tabler_icons_tsx@0.0.6/tsx/",
"@effect/schema": "npm:@effect/schema@^0.75.5",
"@headlessui/react": "https://esm.sh/v135/*@headlessui/react@2.0.4",
"@mdx-js/mdx": "npm:@mdx-js/mdx@3.0.1",
"@preact/signals": "https://esm.sh/v135/*@preact/signals@1.2.3",
Expand All @@ -37,7 +38,7 @@
"@tailwindcss/typography": "npm:@tailwindcss/typography@0.5.13",
"@vendor/": "./vendor/",
"clsx": "npm:clsx@2.1.1",
"effect": "npm:effect@3.3.1",
"effect": "npm:effect@3.9.2",
"idb-keyval": "npm:idb-keyval@6.2.1",
"openai": "https://deno.land/x/openai@v4.50.0/mod.ts",
"openai/": "https://deno.land/x/openai@v4.50.0/",
Expand Down
14 changes: 6 additions & 8 deletions src/utils/solutions.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Schema } from "@effect/schema";
import { assertEquals, assertThrows } from "@std/assert";
import { ZodError } from "zod";
import { solutionPagesSchema } from "./solutions.ts";
import { SolutionPagesSchema } from "./solutions.ts";

/**
* Test the solution pages schema.
Expand All @@ -21,28 +22,25 @@ Deno.test("Solution pages schema.", async (t: Deno.TestContext): Promise<void> =
},
];

const actual = solutionPagesSchema.parse(value);
const actual = Schema.decodeUnknownSync(SolutionPagesSchema)(value);
assertEquals(actual, value);
});

await t.step("Invalid data", async (t): Promise<void> => {
await t.step("Empty", (): void => {
const actual = (): void => {
solutionPagesSchema.parse([{}]);
};
const actual = () => Schema.decodeUnknownSync(SolutionPagesSchema)([{}]);
assertThrows(actual, Error, "Required");
});
await t.step("Missing category", (): void => {
const actual = (): void => {
solutionPagesSchema.parse([
const actual = () =>
Schema.decodeUnknownSync(SolutionPagesSchema)([
{
data: {
title: "title",
description: "description",
},
},
]);
};
assertThrows(actual, ZodError, "Required");
});
});
Expand Down
108 changes: 56 additions & 52 deletions src/utils/solutions.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { z } from "zod";
import { Schema } from "@effect/schema";

/**
* The metadata for a solution.
*/
export type SolutionData = z.infer<typeof solutionDataSchema>;
export type SolutionData = typeof SolutionDataSchema.Type;

/**
* The set of solution pages with their metadata.
*/
export type SolutionPages = z.infer<typeof solutionPagesSchema>;
export type SolutionPages = typeof SolutionPagesSchema.Type;

export type SolutionPage = z.infer<typeof solutionPageSchema>;

const solutionDataSchemaDescription = "Metadata for the solution." as const;
export type SolutionPage = typeof SolutionPageSchema.Type;

/**
* A list of categories, in order.
Expand All @@ -24,45 +22,52 @@ export const titleList = ["what", "environment", "cost", "worth-it"];
/**
* Represent the data for the solution pages.
*/
export const solutionDataSchema = z
.object({
title: z.string().describe("The title of the solution."),
description: z
.string()
.refine((value) => !value.endsWith("."))
.describe("The description of the solution."),
category: z.string().describe("The category of the solution."),
sectionHeader: z
.string()
.describe("The section header for the category index page."),
heroImage: z
.string()
.refine((value) => value.match(avifImageRegex))
.describe("The image to use for the hero."),
icon: z
.string()
.describe("The url of a Tabler icon. Resolves with the import map"),
})
.passthrough()
.readonly()
.describe(solutionDataSchemaDescription);
export const SolutionDataSchema = Schema.Struct({
title: Schema.String.annotations({
description: "The title of the solution.",
}),
description: Schema.String.pipe(
Schema.filter((value) => !value.endsWith(".")),
).annotations({
description: "The description of the solution.",
}),
category: Schema.String.annotations({
description: "The category of the solution.",
}),
sectionHeader: Schema.String.annotations({
description: "The section header for the category index page.",
}),
heroImage: Schema.String.pipe(
Schema.filter((value) => avifImageRegex.test(value)),
).annotations({
description: "The image to use for the hero.",
}),
icon: Schema.String.annotations({
description: "The url of a Tabler icon. Resolves with the import map",
}),
}).annotations({
description: "Metadata for the solution.",
parseOptions: {
onExcessProperty: "preserve",
},
});

const avifImageRegex = /images\/.+.avif$/;

/**
* Represent a set of solution pages.
*/
export const solutionPageSchema = z
.object({
slug: z
.string()
.optional()
.describe("The slug of the solution without a trailing slash."),
data: solutionDataSchema,
})
.strict()
.readonly()
.describe("A solution page.");
export const SolutionPageSchema = Schema.Struct({
slug: Schema.String.pipe(Schema.optional).annotations({
description: "The slug of the solution without a trailing slash.",
}),
data: SolutionDataSchema,
}).annotations({
description: "A solution page.",
parseOptions: {
onExcessProperty: "error",
},
});

/**
* Represent a set of possible solution pages.
Expand All @@ -72,14 +77,14 @@ export const solutionPageSchema = z
*
* Just for typechecking.
*/
const solutionPageNullableSchema = solutionPageSchema.optional();
const SolutionPageNullableSchema = SolutionPageSchema.pipe(Schema.partial);

/**
* Represent a set of possible solution pages.
*/
const solutionPagesNullableSchema = solutionPageNullableSchema
.array()
.readonly();
const SolutionPagesNullableSchema = SolutionPageNullableSchema.pipe(
Schema.Array,
);

/**
* Represent the data for the solution pages.
Expand All @@ -92,13 +97,12 @@ const solutionPagesNullableSchema = solutionPageNullableSchema
* This is used to verify that the MDX frontmatter is correct when the category code is generated.
* This also generates types so that TypeScript can typecheck solutions at compile time.
*/
export const solutionPagesSchema = solutionPagesNullableSchema.transform(
(
val: z.infer<typeof solutionPagesNullableSchema>,
): readonly z.infer<typeof solutionPageSchema>[] =>
val.filter(
(
val: z.infer<typeof solutionPageNullableSchema>,
): val is z.infer<typeof solutionPageSchema> => val !== undefined,
),
export const SolutionPagesSchema = Schema.transform(
SolutionPagesNullableSchema,
Schema.typeSchema(SolutionPageSchema).pipe(Schema.Array),
{
strict: true,
decode: (val) => val.filter((val) => Schema.is(SolutionPageSchema)(val)),
encode: (val) => val.filter((val) => val.data !== undefined),
},
);
49 changes: 29 additions & 20 deletions tool/compile-mdx.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type ParseResult, Schema } from "@effect/schema";
import { type CompileOptions, compile } from "@mdx-js/mdx";
import { dirname, fromFileUrl, join, relative, resolve } from "@std/path";
import { Cause, Chunk, Console, Effect, Order, Stream } from "effect";
Expand All @@ -20,8 +21,8 @@ import { matter } from "vfile-matter";
import { type Options as LintOptions, reporter } from "vfile-reporter";
import {
type SolutionData,
SolutionPagesSchema,
categoryList,
solutionPagesSchema,
titleList,
} from "../src/utils/solutions.ts";
import { readTextFile, walkDir, writeTextFile } from "./effect-deno.ts";
Expand Down Expand Up @@ -303,37 +304,45 @@ function staticImportsFile(files: string[], icons: string[]): string {
*/
const categories = (
files: VFile[],
): Effect.Effect<void, Cause.UnknownException, never> =>
): Effect.Effect<
void,
Cause.UnknownException | ParseResult.ParseError,
never
> =>
Effect.gen(function* () {
const fileContent = categoriesFile(files);
const fileContent = yield* categoriesFile(files);

yield* writeGenFile(fileContent, "categories");
});

/**
* Create a file containing the categories of all the files.
*/
function categoriesFile(files: VFile[]): string {
const sortedFiles = files.map((file) => {
const stem = file.stem ?? "";
const category = file.data.matter?.category ?? "";

return {
slug: stem === category ? undefined : stem,
data: {
heroImage: `/images/${category}-${stem}.avif`,
...file.data.matter,
},
};
});
const parsedProfiles = solutionPagesSchema.parse(sortedFiles);
const json = JSON.stringify(parsedProfiles, undefined, 2);
const categoriesFile = (
files: VFile[],
): Effect.Effect<string, ParseResult.ParseError, never> =>
Effect.gen(function* () {
const sortedFiles = files.map((file) => {
const stem = file.stem ?? "";
const category = file.data.matter?.category ?? "";

return {
slug: stem === category ? undefined : stem,
data: {
heroImage: `/images/${category}-${stem}.avif`,
...file.data.matter,
},
};
});
const parsedProfiles =
yield* Schema.decodeUnknown(SolutionPagesSchema)(sortedFiles);
const json = JSON.stringify(parsedProfiles, undefined, 2);

return `import type { SolutionPages } from "./solutions.ts";
return `import type { SolutionPages } from "./solutions.ts";
export const solutions = ${json} as const satisfies SolutionPages;
`;
}
});

/**
* Write a file to the `utils` directory.
Expand Down

0 comments on commit a835e04

Please sign in to comment.