diff --git a/.changeset/cuddly-feet-hug.md b/.changeset/cuddly-feet-hug.md new file mode 100644 index 000000000..6cba0d466 --- /dev/null +++ b/.changeset/cuddly-feet-hug.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Add human readable config verification errors diff --git a/packages/astro/package.json b/packages/astro/package.json index 531f8fdbc..35be76d9b 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -113,7 +113,8 @@ "@types/sass": "^1.16.0", "@types/yargs-parser": "^20.2.0", "astro-scripts": "0.0.1", - "is-windows": "^1.0.2" + "is-windows": "^1.0.2", + "strip-ansi": "^7.0.0" }, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0", diff --git a/packages/astro/src/cli.ts b/packages/astro/src/cli.ts index 17dfea668..348828a26 100644 --- a/packages/astro/src/cli.ts +++ b/packages/astro/src/cli.ts @@ -1,12 +1,11 @@ /* eslint-disable no-console */ -import type { AstroConfig } from './@types/astro'; - -import * as colors from 'kleur/colors'; import { promises as fsPromises } from 'fs'; +import * as colors from 'kleur/colors'; import yargs from 'yargs-parser'; - -import { loadConfig } from './config.js'; +import { z } from 'zod'; +import type { AstroConfig } from './@types/astro'; import { build } from './build.js'; +import { formatConfigError, loadConfig } from './config.js'; import devServer from './dev.js'; import { preview } from './preview.js'; import { reload } from './reload.js'; @@ -113,7 +112,11 @@ async function runCommand(rawRoot: string, cmd: (a: AstroConfig, opts: any) => P return cmd(astroConfig, options); } catch (err) { - console.error(colors.red(err.toString() || err)); + if (err instanceof z.ZodError) { + console.log(formatConfigError(err)); + } else { + console.error(colors.red(err.toString() || err)); + } process.exit(1); } } diff --git a/packages/astro/src/config.ts b/packages/astro/src/config.ts index 3e20559f8..65bd4e400 100644 --- a/packages/astro/src/config.ts +++ b/packages/astro/src/config.ts @@ -1,6 +1,8 @@ import { existsSync } from 'fs'; import getPort from 'get-port'; +import * as colors from 'kleur/colors'; import path from 'path'; +import { pathToFileURL } from 'url'; import { z } from 'zod'; import { AstroConfig, AstroUserConfig } from './@types/astro'; import { addTrailingSlash } from './util.js'; @@ -70,8 +72,8 @@ export const AstroConfigSchema = z.object({ }); /** Turn raw config values into normalized values */ -async function validateConfig(userConfig: any, root: string): Promise { - const fileProtocolRoot = `file://${root}/`; +export async function validateConfig(userConfig: any, root: string): Promise { + const fileProtocolRoot = pathToFileURL(root + path.sep); // We need to extend the global schema to add transforms that are relative to root. // This is type checked against the global schema to make sure we still match. const AstroConfigRelativeSchema = AstroConfigSchema.extend({ @@ -109,6 +111,10 @@ export async function loadConfig(rawRoot: string | undefined, configFileName = ' userConfig = (await import(astroConfigPath.href)).default; } // normalize, validate, and return - const config = await validateConfig(userConfig, root); - return config; + return validateConfig(userConfig, root); +} + +export function formatConfigError(err: z.ZodError) { + const errorList = err.issues.map((issue) => ` ! ${colors.bold(issue.path.join('.'))} ${colors.red(issue.message + '.')}`); + return `${colors.red('[config]')} Astro found issue(s) with your configuration:\n${errorList.join('\n')}`; } diff --git a/packages/astro/src/runtime.ts b/packages/astro/src/runtime.ts index e1770d6e9..c10a3bce4 100644 --- a/packages/astro/src/runtime.ts +++ b/packages/astro/src/runtime.ts @@ -14,9 +14,11 @@ import { startServer as startSnowpackServer, } from 'snowpack'; import { fileURLToPath } from 'url'; -import type { AstroConfig, RSSFunctionArgs, GetStaticPathsArgs, GetStaticPathsResult, ManifestData, Params, RuntimeMode } from './@types/astro'; +import { z } from 'zod'; +import type { AstroConfig, GetStaticPathsArgs, GetStaticPathsResult, ManifestData, Params, RSSFunctionArgs, RuntimeMode } from './@types/astro'; import { generatePaginateFunction } from './build/paginate.js'; import { canonicalURL, getSrcPath, stopTimer } from './build/util.js'; +import { formatConfigError } from './config.js'; import { ConfigManager } from './config_manager.js'; import snowpackExternals from './external.js'; import { debug, info, LogOptions } from './logger.js'; @@ -48,6 +50,7 @@ type LoadResultSuccess = { type LoadResultNotFound = { statusCode: 404; error: Error }; type LoadResultError = { statusCode: 500 } & ( | { type: 'parse-error'; error: ICompileError } + | { type: 'config-error'; error: z.ZodError } | { type: 'ssr'; error: Error } | { type: 'not-found'; error: ICompileError } | { type: 'unknown'; error: Error } @@ -173,6 +176,15 @@ async function load(config: AstroRuntimeConfig, rawPathname: string | undefined) rss: undefined, // TODO: Add back rss support }; } catch (err) { + if (err instanceof z.ZodError) { + console.log(formatConfigError(err)); + return { + statusCode: 500, + type: 'config-error', + error: err, + }; + } + if (err.code === 'parse-error' || err instanceof SyntaxError) { return { statusCode: 500, diff --git a/packages/astro/test/config-validate.test.js b/packages/astro/test/config-validate.test.js new file mode 100644 index 000000000..3c9bdde36 --- /dev/null +++ b/packages/astro/test/config-validate.test.js @@ -0,0 +1,42 @@ +import { z } from 'zod'; +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import stripAnsi from 'strip-ansi'; +import { formatConfigError, validateConfig } from '#astro/config'; + +const ConfigValidate = suite('Config Validation'); + +ConfigValidate('empty user config is valid', async (context) => { + const configError = await validateConfig({}, process.cwd()).catch(err => err); + assert.ok(!(configError instanceof Error)); +}); + +ConfigValidate('Zod errors are returned when invalid config is used', async (context) => { + const configError = await validateConfig({buildOptions: {sitemap: 42}}, process.cwd()).catch(err => err); + assert.ok(configError instanceof z.ZodError); +}); + +ConfigValidate('A validation error can be formatted correctly', async (context) => { + const configError = await validateConfig({buildOptions: {sitemap: 42}}, process.cwd()).catch(err => err); + assert.ok(configError instanceof z.ZodError); + const formattedError = stripAnsi(formatConfigError(configError)); + assert.equal(formattedError, `[config] Astro found issue(s) with your configuration: + ! buildOptions.sitemap Expected boolean, received number.`); +}); + +ConfigValidate('Multiple validation errors can be formatted correctly', async (context) => { + const veryBadConfig = { + renderers: [42], + buildOptions: {pageUrlFormat: 'invalid'}, + pages: {}, + }; + const configError = await validateConfig(veryBadConfig, process.cwd()).catch(err => err); + assert.ok(configError instanceof z.ZodError); + const formattedError = stripAnsi(formatConfigError(configError)); + assert.equal(formattedError, `[config] Astro found issue(s) with your configuration: + ! pages Expected string, received object. + ! renderers.0 Expected string, received number. + ! buildOptions.pageUrlFormat Invalid input.`); +}); + +ConfigValidate.run(); diff --git a/www/astro.config.mjs b/www/astro.config.mjs index d13e39e6f..34fc3cd74 100644 --- a/www/astro.config.mjs +++ b/www/astro.config.mjs @@ -1,6 +1,15 @@ -export default { +// Full Astro Configuration API Documentation: +// https://docs.astro.build/reference/configuration-reference + +// @type-check enabled! +// VSCode and other TypeScript-enabled text editors will provide auto-completion, +// helpful tooltips, and warnings if your exported object is invalid. +// You can disable this by removing "@ts-check" and `@type` comments below. + +// @ts-check +export default /** @type {import('astro').AstroUserConfig} */ ({ buildOptions: { sitemap: true, site: 'https://astro.build/', }, -}; +});