Skip to content

Commit e58ea4d

Browse files
committed
docs: test Zod Mini tab code heights
1 parent e20d02b commit e58ea4d

4 files changed

Lines changed: 291 additions & 11 deletions

File tree

packages/docs/content/api.mdx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,10 +1154,13 @@ To define a *catchall schema* that will be used to validate any unrecognized key
11541154
<Tabs groupId="lib" items={["Zod", "Zod Mini"]}>
11551155
<Tab value="Zod">
11561156
```ts z.object
1157-
const DogWithStrings = z.object({
1158-
name: z.string(),
1159-
age: z.number().optional(),
1160-
}).catchall(z.string());
1157+
const DogWithStrings = z
1158+
.object({
1159+
name: z.string(),
1160+
age: z.number().optional(),
1161+
})
1162+
.catchall(z.string());
1163+
11611164

11621165
DogWithStrings.parse({ name: "Yeller", extraKey: "extraValue" }); //
11631166
DogWithStrings.parse({ name: "Yeller", extraKey: 42 }); //
@@ -1992,12 +1995,10 @@ fileSchema.mime(["image/png", "image/jpeg"]); // multiple MIME types
19921995
```ts
19931996
const fileSchema = z.file();
19941997

1995-
fileSchema.check(
1996-
z.minSize(10_000), // minimum .size (bytes)
1997-
z.maxSize(1_000_000), // maximum .size (bytes)
1998-
z.mime("image/png"), // MIME type
1999-
z.mime(["image/png", "image/jpeg"]); // multiple MIME types
2000-
)
1998+
fileSchema.check(z.minSize(10_000)); // minimum .size (bytes)
1999+
fileSchema.check(z.maxSize(1_000_000)); // maximum .size (bytes)
2000+
fileSchema.check(z.mime("image/png")); // MIME type
2001+
fileSchema.check(z.mime(["image/png", "image/jpeg"])); // multiple MIME types
20012002
```
20022003
</Tab>
20032004
</Tabs>
@@ -2381,6 +2382,7 @@ const schema = z
23812382
.check(z.refine((data) => data.password === data.confirmPassword, {
23822383
message: "Passwords do not match",
23832384
path: ["confirmPassword"],
2385+
23842386
when(payload) { // [!code ++]
23852387
// no issues with `password` or `confirmPassword` // [!code ++]
23862388
return payload.issues.every((iss) => { // [!code ++]
@@ -2872,6 +2874,7 @@ Alternatively, you can pass a function which will be re-executed whenever a catc
28722874
```ts
28732875
const numberWithRandomCatch = z.number().catch((ctx) => {
28742876
ctx.error; // the caught ZodError
2877+
28752878
return Math.random();
28762879
});
28772880

@@ -3139,7 +3142,9 @@ schema.parse(null); // => null
31393142
<Tab value="Zod Mini">
31403143
```ts
31413144
function setCommonNumberChecks<T extends z.ZodMiniNumber>(schema: T) {
3142-
return schema.check(z.minimum(0), z.maximum(100));
3145+
return schema.check(
3146+
z.minimum(0), z.maximum(100)
3147+
);
31433148
}
31443149

31453150
const schema = z.nullable(

packages/docs/content/api.test.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { readFile } from "node:fs/promises";
2+
import { dirname, resolve } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { expect, test } from "vitest";
5+
6+
type SourceLine = {
7+
text: string;
8+
number: number;
9+
};
10+
11+
type CodeFence = {
12+
startLine: number;
13+
lineCount: number;
14+
};
15+
16+
type TabBlock = {
17+
value: string;
18+
line: number;
19+
codeFences: CodeFence[];
20+
};
21+
22+
type TabsBlock = {
23+
startLine: number;
24+
tabs: TabBlock[];
25+
};
26+
27+
const apiDocsPath = resolve(dirname(fileURLToPath(import.meta.url)), "api.mdx");
28+
29+
test("Zod and Zod Mini tabbed API examples have matching code block heights", async () => {
30+
const source = await readFile(apiDocsPath, "utf8");
31+
const tabsBlocks = extractTabsBlocks(source);
32+
const failures: string[] = [];
33+
let comparedBlocks = 0;
34+
35+
for (const block of tabsBlocks) {
36+
assertExpectedTabLabels(block, failures);
37+
38+
const zodTab = block.tabs.find((tab) => tab.value === "Zod");
39+
const zodMiniTab = block.tabs.find((tab) => tab.value === "Zod Mini");
40+
41+
if (!zodTab || !zodMiniTab) {
42+
continue;
43+
}
44+
45+
comparedBlocks += 1;
46+
compareCodeFences(block, zodTab, zodMiniTab, failures);
47+
}
48+
49+
expect(comparedBlocks).toBeGreaterThan(0);
50+
expect(failures).toEqual([]);
51+
});
52+
53+
function extractTabsBlocks(source: string): TabsBlock[] {
54+
const lines = stripMdxComments(source);
55+
const blocks: TabsBlock[] = [];
56+
57+
for (let index = 0; index < lines.length; index++) {
58+
const line = lines[index];
59+
60+
if (!line || !/<Tabs\b/.test(line.text)) {
61+
continue;
62+
}
63+
64+
const tabs: TabBlock[] = [];
65+
let currentTab: TabBlock | undefined;
66+
67+
for (index += 1; index < lines.length; index++) {
68+
const blockLine = lines[index];
69+
70+
if (!blockLine || /<\/Tabs>/.test(blockLine.text)) {
71+
break;
72+
}
73+
74+
const tabValue = getTabValue(blockLine.text);
75+
76+
if (tabValue) {
77+
currentTab = { value: tabValue, line: blockLine.number, codeFences: [] };
78+
tabs.push(currentTab);
79+
continue;
80+
}
81+
82+
if (/<\/Tab>/.test(blockLine.text)) {
83+
currentTab = undefined;
84+
continue;
85+
}
86+
87+
if (currentTab && blockLine.text.trimStart().startsWith("```")) {
88+
const codeFence = readCodeFence(lines, index, blockLine.number);
89+
currentTab.codeFences.push(codeFence);
90+
index += codeFence.lineCount + 1;
91+
}
92+
}
93+
94+
blocks.push({ startLine: line.number, tabs });
95+
}
96+
97+
return blocks;
98+
}
99+
100+
function stripMdxComments(source: string): SourceLine[] {
101+
const lines = source.split(/\r?\n/);
102+
const sourceLines: SourceLine[] = [];
103+
let inCodeFence = false;
104+
const mdxCommentState = { inComment: false };
105+
106+
for (let index = 0; index < lines.length; index++) {
107+
const line = lines[index] ?? "";
108+
const isFenceBoundary = line.trimStart().startsWith("```");
109+
110+
if (isFenceBoundary) {
111+
inCodeFence = !inCodeFence;
112+
}
113+
114+
sourceLines.push({
115+
text: inCodeFence ? line : stripMdxCommentSegments(line, mdxCommentState),
116+
number: index + 1,
117+
});
118+
}
119+
120+
return sourceLines;
121+
}
122+
123+
function stripMdxCommentSegments(line: string, state: { inComment: boolean }): string {
124+
let result = "";
125+
let index = 0;
126+
127+
while (index < line.length) {
128+
if (state.inComment) {
129+
const commentEnd = line.indexOf("*/}", index);
130+
131+
if (commentEnd === -1) {
132+
return result;
133+
}
134+
135+
state.inComment = false;
136+
index = commentEnd + 3;
137+
continue;
138+
}
139+
140+
const commentStart = line.indexOf("{/*", index);
141+
142+
if (commentStart === -1) {
143+
result += line.slice(index);
144+
break;
145+
}
146+
147+
result += line.slice(index, commentStart);
148+
state.inComment = true;
149+
index = commentStart + 3;
150+
}
151+
152+
return result;
153+
}
154+
155+
function getTabValue(line: string): string | undefined {
156+
const quotedValue = line.match(/<Tab\b[^>]*\bvalue\s*=\s*(["'])(.*?)\1/);
157+
158+
if (quotedValue?.[2]) {
159+
return quotedValue[2];
160+
}
161+
162+
const expressionValue = line.match(/<Tab\b[^>]*\bvalue\s*=\s*\{\s*(["'])(.*?)\1\s*\}/);
163+
return expressionValue?.[2];
164+
}
165+
166+
function readCodeFence(lines: SourceLine[], fenceStartIndex: number, startLine: number): CodeFence {
167+
let lineCount = 0;
168+
169+
for (let index = fenceStartIndex + 1; index < lines.length; index++) {
170+
const line = lines[index];
171+
172+
if (!line || line.text.trimStart().startsWith("```")) {
173+
break;
174+
}
175+
176+
lineCount += 1;
177+
}
178+
179+
return { startLine, lineCount };
180+
}
181+
182+
function assertExpectedTabLabels(block: TabsBlock, failures: string[]): void {
183+
const hasZodishTab = block.tabs.some((tab) => isLikelyTabValue(tab.value, "Zod"));
184+
const hasZodMiniishTab = block.tabs.some((tab) => isLikelyTabValue(tab.value, "Zod Mini"));
185+
186+
if (!hasZodishTab || !hasZodMiniishTab) {
187+
return;
188+
}
189+
190+
for (const tab of block.tabs) {
191+
for (const expectedValue of ["Zod", "Zod Mini"]) {
192+
if (tab.value !== expectedValue && isLikelyTabValue(tab.value, expectedValue)) {
193+
failures.push(
194+
`api.mdx:${tab.line} has likely ${expectedValue} tab label typo: expected value="${expectedValue}", found value="${tab.value}".`
195+
);
196+
}
197+
}
198+
}
199+
}
200+
201+
function compareCodeFences(block: TabsBlock, zodTab: TabBlock, zodMiniTab: TabBlock, failures: string[]): void {
202+
if (zodTab.codeFences.length !== zodMiniTab.codeFences.length) {
203+
failures.push(
204+
`api.mdx:${block.startLine} has ${zodTab.codeFences.length} Zod code fence(s) and ${zodMiniTab.codeFences.length} Zod Mini code fence(s).`
205+
);
206+
return;
207+
}
208+
209+
for (let index = 0; index < zodTab.codeFences.length; index++) {
210+
const zodFence = zodTab.codeFences[index];
211+
const zodMiniFence = zodMiniTab.codeFences[index];
212+
213+
if (!zodFence || !zodMiniFence || zodFence.lineCount === zodMiniFence.lineCount) {
214+
continue;
215+
}
216+
217+
failures.push(
218+
`api.mdx:${block.startLine} code fence #${index + 1} has ${zodFence.lineCount} Zod line(s) at line ${zodFence.startLine} and ${zodMiniFence.lineCount} Zod Mini line(s) at line ${zodMiniFence.startLine}.`
219+
);
220+
}
221+
}
222+
223+
function isLikelyTabValue(actual: string, expected: string): boolean {
224+
const normalizedActual = normalizeTabValue(actual);
225+
const normalizedExpected = normalizeTabValue(expected);
226+
return normalizedActual === normalizedExpected || getEditDistance(normalizedActual, normalizedExpected) <= 1;
227+
}
228+
229+
function normalizeTabValue(value: string): string {
230+
return value.toLowerCase().replaceAll(/[\s_-]/g, "");
231+
}
232+
233+
function getEditDistance(left: string, right: string): number {
234+
let previousRow = Array.from({ length: right.length + 1 }, (_, index) => index);
235+
236+
for (let leftIndex = 0; leftIndex < left.length; leftIndex++) {
237+
const currentRow = [leftIndex + 1];
238+
239+
for (let rightIndex = 0; rightIndex < right.length; rightIndex++) {
240+
const substitutionCost = left[leftIndex] === right[rightIndex] ? 0 : 1;
241+
currentRow.push(
242+
Math.min(
243+
currentRow[rightIndex] + 1,
244+
previousRow[rightIndex + 1] + 1,
245+
previousRow[rightIndex] + substitutionCost
246+
)
247+
);
248+
}
249+
250+
previousRow = currentRow;
251+
}
252+
253+
return previousRow[right.length] ?? 0;
254+
}

packages/docs/tsconfig.test.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"types": ["node", "vitest"]
5+
},
6+
"include": ["**/*.test.ts"]
7+
}

packages/docs/vitest.config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { defineProject, mergeConfig } from "vitest/config";
2+
import rootConfig from "../../vitest.root.mjs";
3+
4+
export default mergeConfig(
5+
rootConfig,
6+
defineProject({
7+
test: {
8+
environment: "node",
9+
typecheck: {
10+
tsconfig: "./tsconfig.test.json",
11+
},
12+
},
13+
})
14+
);

0 commit comments

Comments
 (0)