Skip to content

Commit

Permalink
Add ZodErrorMap (#101)
Browse files Browse the repository at this point in the history
Co-authored-by: Chris Swithinbank <[email protected]>
  • Loading branch information
TheOtterlord and delucis authored May 30, 2023
1 parent fe0a9b3 commit 6a2c0df
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/hot-dragons-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@astrojs/starlight": patch
---

Add better error messages for starlight config
12 changes: 11 additions & 1 deletion packages/starlight/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,21 @@ import {
StarlightConfig,
StarlightConfigSchema,
} from './utils/user-config';
import { errorMap } from './utils/error-map';

export default function StarlightIntegration(
opts: StarlightUserConfig
): AstroIntegration[] {
const userConfig = StarlightConfigSchema.parse(opts);
const parsedConfig = StarlightConfigSchema.safeParse(opts, { errorMap });

if (!parsedConfig.success) {
throw new Error(
'Invalid config passed to starlight integration\n' +
parsedConfig.error.issues.map((i) => i.message).join('\n')
);
}

const userConfig = parsedConfig.data;

const Starlight: AstroIntegration = {
name: '@astrojs/starlight',
Expand Down
104 changes: 104 additions & 0 deletions packages/starlight/utils/error-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* This is a modified version of Astro's error map.
* source: https://github.com/withastro/astro/blob/main/packages/astro/src/content/error-map.ts
*/

import type { z } from 'astro:content';

type TypeOrLiteralErrByPathEntry = {
code: 'invalid_type' | 'invalid_literal';
received: unknown;
expected: unknown[];
};

export const errorMap: z.ZodErrorMap = (baseError, ctx) => {
const baseErrorPath = flattenErrorPath(baseError.path);
if (baseError.code === 'invalid_union') {
// Optimization: Combine type and literal errors for keys that are common across ALL union types
// Ex. a union between `{ key: z.literal('tutorial') }` and `{ key: z.literal('blog') }` will
// raise a single error when `key` does not match:
// > Did not match union.
// > key: Expected `'tutorial' | 'blog'`, received 'foo'
let typeOrLiteralErrByPath: Map<string, TypeOrLiteralErrByPathEntry> = new Map();
for (const unionError of baseError.unionErrors.map((e) => e.errors).flat()) {
if (unionError.code === 'invalid_type' || unionError.code === 'invalid_literal') {
const flattenedErrorPath = flattenErrorPath(unionError.path);
if (typeOrLiteralErrByPath.has(flattenedErrorPath)) {
typeOrLiteralErrByPath.get(flattenedErrorPath)!.expected.push(unionError.expected);
} else {
typeOrLiteralErrByPath.set(flattenedErrorPath, {
code: unionError.code,
received: (unionError as any).received,
expected: [unionError.expected],
});
}
}
}
let messages: string[] = [
prefix(
baseErrorPath,
typeOrLiteralErrByPath.size ? 'Did not match union:' : 'Did not match union.'
),
];
return {
message: messages
.concat(
[...typeOrLiteralErrByPath.entries()]
// If type or literal error isn't common to ALL union types,
// filter it out. Can lead to confusing noise.
.filter(([, error]) => error.expected.length === baseError.unionErrors.length)
.map(([key, error]) =>
key === baseErrorPath
? // Avoid printing the key again if it's a base error
`> ${getTypeOrLiteralMsg(error)}`
: `> ${prefix(key, getTypeOrLiteralMsg(error))}`
)
)
.join('\n'),
};
}
if (baseError.code === 'invalid_literal' || baseError.code === 'invalid_type') {
return {
message: prefix(
baseErrorPath,
getTypeOrLiteralMsg({
code: baseError.code,
received: (baseError as any).received,
expected: [baseError.expected],
})
),
};
} else if (baseError.message) {
return { message: prefix(baseErrorPath, baseError.message) };
} else {
return { message: prefix(baseErrorPath, ctx.defaultError) };
}
};

const getTypeOrLiteralMsg = (error: TypeOrLiteralErrByPathEntry): string => {
if (error.received === 'undefined') return 'Required';
const expectedDeduped = new Set(error.expected);
switch (error.code) {
case 'invalid_type':
return `Expected type \`${unionExpectedVals(expectedDeduped)}\`, received ${JSON.stringify(
error.received
)}`;
case 'invalid_literal':
return `Expected \`${unionExpectedVals(expectedDeduped)}\`, received ${JSON.stringify(
error.received
)}`;
}
};

const prefix = (key: string, msg: string) => (key.length ? `**${key}**: ${msg}` : msg);

const unionExpectedVals = (expectedVals: Set<unknown>) =>
[...expectedVals]
.map((expectedVal, idx) => {
if (idx === 0) return JSON.stringify(expectedVal);
const sep = ' | ';
return `${sep}${JSON.stringify(expectedVal)}`;
})
.join('');

const flattenErrorPath = (errorPath: (string | number)[]) => errorPath.join('.');

0 comments on commit 6a2c0df

Please sign in to comment.