Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve Zod errors #1542

Merged
merged 20 commits into from
Feb 23, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions packages/starlight/__tests__/basics/config-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { expect, test } from 'vitest';
import { parseWithFriendlyErrors } from '../../utils/error-map';
import { StarlightConfigSchema, type StarlightUserConfig } from '../../utils/user-config';

function parseStarlightConfigWithFriendlyErrors(config: StarlightUserConfig) {
return parseWithFriendlyErrors(
StarlightConfigSchema,
config,
'Invalid config passed to starlight integration'
);
}

test('parses valid config successfully', () => {
const data = parseStarlightConfigWithFriendlyErrors({ title: '' });
expect(data).toMatchInlineSnapshot(`
{
"components": {
"Banner": "@astrojs/starlight/components/Banner.astro",
"ContentPanel": "@astrojs/starlight/components/ContentPanel.astro",
"EditLink": "@astrojs/starlight/components/EditLink.astro",
"FallbackContentNotice": "@astrojs/starlight/components/FallbackContentNotice.astro",
"Footer": "@astrojs/starlight/components/Footer.astro",
"Head": "@astrojs/starlight/components/Head.astro",
"Header": "@astrojs/starlight/components/Header.astro",
"Hero": "@astrojs/starlight/components/Hero.astro",
"LanguageSelect": "@astrojs/starlight/components/LanguageSelect.astro",
"LastUpdated": "@astrojs/starlight/components/LastUpdated.astro",
"MarkdownContent": "@astrojs/starlight/components/MarkdownContent.astro",
"MobileMenuFooter": "@astrojs/starlight/components/MobileMenuFooter.astro",
"MobileMenuToggle": "@astrojs/starlight/components/MobileMenuToggle.astro",
"MobileTableOfContents": "@astrojs/starlight/components/MobileTableOfContents.astro",
"PageFrame": "@astrojs/starlight/components/PageFrame.astro",
"PageSidebar": "@astrojs/starlight/components/PageSidebar.astro",
"PageTitle": "@astrojs/starlight/components/PageTitle.astro",
"Pagination": "@astrojs/starlight/components/Pagination.astro",
"Search": "@astrojs/starlight/components/Search.astro",
"Sidebar": "@astrojs/starlight/components/Sidebar.astro",
"SiteTitle": "@astrojs/starlight/components/SiteTitle.astro",
"SkipLink": "@astrojs/starlight/components/SkipLink.astro",
"SocialIcons": "@astrojs/starlight/components/SocialIcons.astro",
"TableOfContents": "@astrojs/starlight/components/TableOfContents.astro",
"ThemeProvider": "@astrojs/starlight/components/ThemeProvider.astro",
"ThemeSelect": "@astrojs/starlight/components/ThemeSelect.astro",
"TwoColumnContent": "@astrojs/starlight/components/TwoColumnContent.astro",
},
"customCss": [],
"defaultLocale": {
"dir": "ltr",
"label": "English",
"lang": "en",
"locale": undefined,
},
"disable404Route": false,
"editLink": {},
"favicon": {
"href": "/favicon.svg",
"type": "image/svg+xml",
},
"head": [],
"isMultilingual": false,
"lastUpdated": false,
"locales": undefined,
"pagefind": true,
"pagination": true,
"tableOfContents": {
"maxHeadingLevel": 3,
"minHeadingLevel": 2,
},
"title": "",
"titleDelimiter": "|",
}
`);
});

test('errors if title is missing', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({} as any)
).toThrowErrorMatchingInlineSnapshot(
`
[Error: Invalid config passed to starlight integration
**title**: Required]
`
);
});

test('errors if title value is not a string', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({ title: 5 } as any)
).toThrowErrorMatchingInlineSnapshot(
`
[Error: Invalid config passed to starlight integration
**title**: Expected type \`"string"\`, received "number"]
`
);
});

test('errors with bad social icon config', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({ title: 'Test', social: { unknown: '' } as any })
).toThrowErrorMatchingInlineSnapshot(
`
[Error: Invalid config passed to starlight integration
**social.unknown**: Invalid enum value. Expected 'twitter' | 'mastodon' | 'github' | 'gitlab' | 'bitbucket' | 'discord' | 'gitter' | 'codeberg' | 'codePen' | 'youtube' | 'threads' | 'linkedin' | 'twitch' | 'microsoftTeams' | 'instagram' | 'stackOverflow' | 'x.com' | 'telegram' | 'rss' | 'facebook' | 'email' | 'reddit' | 'patreon' | 'slack' | 'matrix' | 'openCollective', received 'unknown'
**social.unknown**: Invalid url]
`
);
});

test('errors with bad logo config', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({ title: 'Test', logo: { html: '' } as any })
).toThrowErrorMatchingInlineSnapshot(
`
[Error: Invalid config passed to starlight integration
**logo**: Did not match union.
> Expected type \`{ src: string } | { dark: string; light: string }\`
> Received {"html":""}]
`
);
});

test('errors with bad head config', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({
title: 'Test',
head: [{ tag: 'unknown', attrs: { prop: null }, content: 20 } as any],
})
).toThrowErrorMatchingInlineSnapshot(
`
[Error: Invalid config passed to starlight integration
**head.0.tag**: Invalid enum value. Expected 'title' | 'base' | 'link' | 'style' | 'meta' | 'script' | 'noscript' | 'template', received 'unknown'
**head.0.attrs.prop**: Did not match union.
> Expected type \`"string" | "boolean" | "undefined"\`, received "null"
**head.0.content**: Expected type \`"string"\`, received "number"]
`
);
});

test('errors with bad sidebar config', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({
title: 'Test',
sidebar: [{ label: 'Example', href: '/' } as any],
})
).toThrowErrorMatchingInlineSnapshot(
`
[Error: Invalid config passed to starlight integration
**sidebar.0**: Did not match union.
> Expected type \`{ link: string } | { items: array } | { autogenerate: object }\`
> Received {"label":"Example","href":"/"}]
`
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,9 @@ test('throws error if sidebar is malformated', async () => {
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
[Error: Invalid sidebar prop passed to the \`<StarlightPage/>\` component.
**0**: Did not match union:]
**0**: Did not match union.
> Expected type \`{ href: string } | { entries: array }\`
> Received {"label":"Custom link 1","href":5}]
`);
});

Expand Down
84 changes: 54 additions & 30 deletions packages/starlight/utils/error-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,27 @@ type TypeOrLiteralErrByPathEntry = {
expected: unknown[];
};

export function throwValidationError(error: z.ZodError, message: string): never {
throw new Error(`${message}\n${error.issues.map((i) => i.message).join('\n')}`);
/**
* Parse data with a Zod schema and throw a nicely formatted error if it is invalid.
*
* @param schema The Zod schema to use to parse the input.
* @param input Input data that should match the schema.
* @param message Error message preamble to use if the input fails to parse.
* @returns Validated data parsed by Zod.
*/
export function parseWithFriendlyErrors<T extends z.Schema>(
schema: T,
input: z.input<T>,
message: string
): z.output<T> {
const parsedConfig = schema.safeParse(input, { errorMap });
if (!parsedConfig.success) {
throw new Error(message + '\n' + parsedConfig.error.issues.map((i) => i.message).join('\n'));
}
return parsedConfig.data;
}

export const errorMap: z.ZodErrorMap = (baseError, ctx) => {
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
Expand All @@ -38,27 +54,41 @@ export const errorMap: z.ZodErrorMap = (baseError, ctx) => {
}
}
}
let messages: string[] = [
prefix(
baseErrorPath,
typeOrLiteralErrByPath.size ? 'Did not match union:' : 'Did not match union.'
),
];
const messages: string[] = [prefix(baseErrorPath, 'Did not match union.')];
const details: string[] = [...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))}`
);

if (details.length === 0) {
const expectedShapes = baseError.unionErrors.map((unionError) => {
const props = unionError.issues
.map((issue) => {
const relativePath = flattenErrorPath(issue.path)
.replace(baseErrorPath, '')
.replace(/^\./, '');
if ('expected' in issue) {
return relativePath ? `${relativePath}: ${issue.expected}` : issue.expected;
}
return relativePath;
})
.join('; ');
return `{ ${props} }`;
});
delucis marked this conversation as resolved.
Show resolved Hide resolved
if (expectedShapes.length) {
details.push('> Expected type `' + expectedShapes.join(' | ') + '`');
details.push('> Received ' + JSON.stringify(ctx.data));
}
}

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'),
message: messages.concat(details).join('\n'),
};
}
if (baseError.code === 'invalid_literal' || baseError.code === 'invalid_type') {
Expand Down Expand Up @@ -97,12 +127,6 @@ const getTypeOrLiteralMsg = (error: TypeOrLiteralErrByPathEntry): string => {
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('');
[...expectedVals].map((expectedVal) => JSON.stringify(expectedVal)).join(' | ');

const flattenErrorPath = (errorPath: (string | number)[]) => errorPath.join('.');
43 changes: 18 additions & 25 deletions packages/starlight/utils/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AstroIntegration } from 'astro';
import { z } from 'astro/zod';
import { StarlightConfigSchema, type StarlightUserConfig } from '../utils/user-config';
import { errorMap, throwValidationError } from '../utils/error-map';
import { parseWithFriendlyErrors } from '../utils/error-map';

/**
* Runs Starlight plugins in the order that they are configured after validating the user-provided
Expand All @@ -15,31 +15,27 @@ export async function runPlugins(
) {
// Validate the user-provided configuration.
let userConfig = starlightUserConfig;
let starlightConfig = StarlightConfigSchema.safeParse(userConfig, { errorMap });

if (!starlightConfig.success) {
throwValidationError(starlightConfig.error, 'Invalid config passed to starlight integration');
}
let starlightConfig = parseWithFriendlyErrors(
StarlightConfigSchema,
userConfig,
'Invalid config passed to starlight integration'
);

// Validate the user-provided plugins configuration.
const pluginsConfig = starlightPluginsConfigSchema.safeParse(pluginsUserConfig, {
errorMap,
});

if (!pluginsConfig.success) {
throwValidationError(
pluginsConfig.error,
'Invalid plugins config passed to starlight integration'
);
}
const pluginsConfig = parseWithFriendlyErrors(
starlightPluginsConfigSchema,
pluginsUserConfig,
'Invalid plugins config passed to starlight integration'
);

// A list of Astro integrations added by the various plugins.
const integrations: AstroIntegration[] = [];

for (const {
name,
hooks: { setup },
} of pluginsConfig.data) {
} of pluginsConfig) {
await setup({
config: pluginsUserConfig ? { ...userConfig, plugins: pluginsUserConfig } : userConfig,
updateConfig(newConfig) {
Expand All @@ -52,14 +48,11 @@ export async function runPlugins(

// If the plugin is updating the user config, re-validate it.
const mergedUserConfig = { ...userConfig, ...newConfig };
const mergedConfig = StarlightConfigSchema.safeParse(mergedUserConfig, { errorMap });

if (!mergedConfig.success) {
throwValidationError(
mergedConfig.error,
`Invalid config update provided by the '${name}' plugin`
);
}
const mergedConfig = parseWithFriendlyErrors(
StarlightConfigSchema,
mergedUserConfig,
`Invalid config update provided by the '${name}' plugin`
);

// If the updated config is valid, keep track of both the user config and parsed config.
userConfig = mergedUserConfig;
Expand All @@ -79,7 +72,7 @@ export async function runPlugins(
});
}

return { integrations, starlightConfig: starlightConfig.data };
return { integrations, starlightConfig };
}

// https://github.com/withastro/astro/blob/910eb00fe0b70ca80bd09520ae100e8c78b675b5/packages/astro/src/core/config/schema.ts#L113
Expand Down
Loading
Loading