diff options
19 files changed, 800 insertions, 73 deletions
diff --git a/.changeset/happy-chefs-ring.md b/.changeset/happy-chefs-ring.md new file mode 100644 index 000000000..c8ac61ddb --- /dev/null +++ b/.changeset/happy-chefs-ring.md @@ -0,0 +1,5 @@ +--- +'astro': minor +--- + +Add a new error overlay designed by @doodlemarks! This new overlay should be much more informative, clearer, astro-y, and prettier than the previous one. diff --git a/packages/astro/e2e/error-cyclic.test.js b/packages/astro/e2e/error-cyclic.test.js index 78c3bd1ea..ef17a32d3 100644 --- a/packages/astro/e2e/error-cyclic.test.js +++ b/packages/astro/e2e/error-cyclic.test.js @@ -1,7 +1,10 @@ import { expect } from '@playwright/test'; -import { testFactory, getErrorOverlayMessage } from './test-utils.js'; +import { testFactory, getErrorOverlayContent } from './test-utils.js'; -const test = testFactory({ root: './fixtures/error-cyclic/' }); +const test = testFactory({ + experimentalErrorOverlay: true, + root: './fixtures/error-cyclic/' +}); let devServer; @@ -18,7 +21,7 @@ test.describe('Error: Cyclic Reference', () => { test('overlay', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - const message = await getErrorOverlayMessage(page); + const message = (await getErrorOverlayContent(page)).message; expect(message).toMatch('Cyclic reference'); }); }); diff --git a/packages/astro/e2e/error-react-spectrum.test.js b/packages/astro/e2e/error-react-spectrum.test.js index 63934c9ca..618859ac1 100644 --- a/packages/astro/e2e/error-react-spectrum.test.js +++ b/packages/astro/e2e/error-react-spectrum.test.js @@ -1,7 +1,10 @@ import { expect } from '@playwright/test'; -import { testFactory, getErrorOverlayMessage } from './test-utils.js'; +import { testFactory, getErrorOverlayContent } from './test-utils.js'; -const test = testFactory({ root: './fixtures/error-react-spectrum/' }); +const test = testFactory({ + experimentalErrorOverlay: true, + root: './fixtures/error-react-spectrum/' +}); let devServer; @@ -17,7 +20,7 @@ test.describe('Error: React Spectrum', () => { test('overlay', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - const message = await getErrorOverlayMessage(page); + const message = (await getErrorOverlayContent(page)).hint; expect(message).toMatch('@adobe/react-spectrum is not compatible'); }); }); diff --git a/packages/astro/e2e/error-sass.test.js b/packages/astro/e2e/error-sass.test.js index e7b87e105..2eeab13df 100644 --- a/packages/astro/e2e/error-sass.test.js +++ b/packages/astro/e2e/error-sass.test.js @@ -1,7 +1,10 @@ import { expect } from '@playwright/test'; -import { testFactory, getErrorOverlayMessage } from './test-utils.js'; +import { testFactory, getErrorOverlayContent } from './test-utils.js'; -const test = testFactory({ root: './fixtures/error-sass/' }); +const test = testFactory({ + experimentalErrorOverlay: true, + root: './fixtures/error-sass/' +}); let devServer; @@ -18,7 +21,7 @@ test.describe('Error: Sass', () => { test('overlay', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - const message = await getErrorOverlayMessage(page); + const message = (await getErrorOverlayContent(page)).message; expect(message).toMatch('Undefined variable'); }); }); diff --git a/packages/astro/e2e/errors.test.js b/packages/astro/e2e/errors.test.js index c9aff5269..34f3c59ad 100644 --- a/packages/astro/e2e/errors.test.js +++ b/packages/astro/e2e/errors.test.js @@ -1,7 +1,10 @@ import { expect } from '@playwright/test'; -import { getErrorOverlayMessage, testFactory } from './test-utils.js'; +import { getErrorOverlayContent, testFactory } from './test-utils.js'; -const test = testFactory({ root: './fixtures/errors/' }); +const test = testFactory({ + experimentalErrorOverlay: true, + root: './fixtures/errors/' +}); let devServer; @@ -18,7 +21,7 @@ test.describe('Error display', () => { test('detect syntax errors in template', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/astro-syntax-error')); - const message = await getErrorOverlayMessage(page); + const message = (await getErrorOverlayContent(page)).message; expect(message).toMatch('Unexpected "}"'); await Promise.all([ @@ -37,10 +40,8 @@ test.describe('Error display', () => { test('shows useful error when frontmatter import is not found', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/import-not-found')); - const message = await getErrorOverlayMessage(page); - expect(message).toMatch( - 'Could not import `../abc.astro`.\n\nThis is often caused by a typo in the import path. Please make sure the file exists.' - ); + const message = (await getErrorOverlayContent(page)).message; + expect(message).toMatch('Could not import ../abc.astro'); await Promise.all([ // Wait for page reload @@ -55,7 +56,7 @@ test.describe('Error display', () => { test('framework errors recover when fixed', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/svelte-syntax-error')); - const message = await getErrorOverlayMessage(page); + const message = (await getErrorOverlayContent(page)).message; expect(message).toMatch('</div> attempted to close an element that was not open'); await Promise.all([ diff --git a/packages/astro/e2e/shared-component-tests.js b/packages/astro/e2e/shared-component-tests.js index db8620835..92423c1c0 100644 --- a/packages/astro/e2e/shared-component-tests.js +++ b/packages/astro/e2e/shared-component-tests.js @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { testFactory, getErrorOverlayMessage } from './test-utils.js'; +import { testFactory } from './test-utils.js'; export function prepareTestFactory(opts) { const test = testFactory(opts); diff --git a/packages/astro/e2e/test-utils.js b/packages/astro/e2e/test-utils.js index 2a8651fd5..b75efeec4 100644 --- a/packages/astro/e2e/test-utils.js +++ b/packages/astro/e2e/test-utils.js @@ -32,7 +32,12 @@ export function testFactory(inlineConfig) { return test; } -export async function getErrorOverlayMessage(page) { +/** + * + * @param {string} page + * @returns {Promise<{message: string, hint: string}>} + */ +export async function getErrorOverlayContent(page) { const overlay = await page.waitForSelector('vite-error-overlay', { strict: true, timeout: 10 * 1000, @@ -40,7 +45,10 @@ export async function getErrorOverlayMessage(page) { expect(overlay).toBeTruthy(); - return await overlay.$$eval('.message-body', (m) => m[0].textContent); + const message = await overlay.$$eval('#message-content', (m) => m[0].textContent); + const hint = await overlay.$$eval('#hint-content', (m) => m[0].textContent); + + return { message, hint }; } /** diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 1b6c98ea5..55620c745 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -82,6 +82,7 @@ export interface CLIFlags { port?: number; config?: string; drafts?: boolean; + experimentalErrorOverlay?: boolean; } export interface BuildConfig { @@ -894,6 +895,12 @@ export interface AstroUserConfig { astroFlavoredMarkdown?: boolean; }; + /** + * @hidden + * Turn on experimental support for the new error overlay component. + */ + experimentalErrorOverlay?: boolean; + // Legacy options to be removed /** @deprecated - Use "integrations" instead. Run Astro to learn more about migrating. */ diff --git a/packages/astro/src/core/compile/style.ts b/packages/astro/src/core/compile/style.ts index 9f32ad3a6..b9a5bbcfa 100644 --- a/packages/astro/src/core/compile/style.ts +++ b/packages/astro/src/core/compile/style.ts @@ -62,6 +62,7 @@ function enhanceCSSError(err: any, filename: string) { line: errorLine, column: err.column, }, + stack: err.stack, }); } @@ -78,6 +79,7 @@ function enhanceCSSError(err: any, filename: string) { column: err.column, }, frame: err.frame, + stack: err.stack, }); } @@ -94,5 +96,6 @@ function enhanceCSSError(err: any, filename: string) { column: 0, }, frame: err.frame, + stack: err.stack, }); } diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts index 25efc0306..864a13eaf 100644 --- a/packages/astro/src/core/config/config.ts +++ b/packages/astro/src/core/config/config.ts @@ -100,6 +100,7 @@ export function resolveFlags(flags: Partial<Flags>): CLIFlags { host: typeof flags.host === 'string' || typeof flags.host === 'boolean' ? flags.host : undefined, drafts: typeof flags.drafts === 'boolean' ? flags.drafts : undefined, + experimentalErrorOverlay: typeof flags.experimentalErrorOverlay === 'boolean' ? flags.experimentalErrorOverlay : undefined, }; } @@ -127,6 +128,7 @@ function mergeCLIFlags(astroConfig: AstroUserConfig, flags: CLIFlags, cmd: strin // TODO: Come back here and refactor to remove this expected error. astroConfig.server.host = flags.host; } + astroConfig.experimentalErrorOverlay = flags.experimentalErrorOverlay ?? false; return astroConfig; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 9e5fc377b..78a0ed60c 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -47,6 +47,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = { legacy: { astroFlavoredMarkdown: false, }, + experimentalErrorOverlay: false, }; export const AstroConfigSchema = z.object({ @@ -196,6 +197,7 @@ export const AstroConfigSchema = z.object({ }) .optional() .default({}), + experimentalErrorOverlay: z.boolean().optional().default(false), }); interface PostCSSConfigResult { diff --git a/packages/astro/src/core/errors/dev/utils.ts b/packages/astro/src/core/errors/dev/utils.ts index 7ee90115c..9843f3b0f 100644 --- a/packages/astro/src/core/errors/dev/utils.ts +++ b/packages/astro/src/core/errors/dev/utils.ts @@ -1,12 +1,15 @@ import * as fs from 'node:fs'; -import { join } from 'node:path'; +import { isAbsolute, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import stripAnsi from 'strip-ansi'; +import { escape } from 'html-escaper'; +import type { BuildResult } from 'esbuild'; import type { ESBuildTransformResult } from 'vite'; import type { SSRError } from '../../../@types/astro.js'; import { AggregateError, ErrorWithMetadata } from '../errors.js'; import { codeFrame } from '../printer.js'; import { normalizeLF } from '../utils.js'; +import { bold, underline } from 'kleur/colors'; type EsbuildMessage = ESBuildTransformResult['warnings'][number]; @@ -30,16 +33,32 @@ export function collectErrorMetadata(e: any, rootFolder?: URL | undefined): Erro error = collectInfoFromStacktrace(e); } - if (error.loc?.file && rootFolder && !error.loc.file.startsWith('/')) { + // Make sure the file location is absolute, otherwise: + // - It won't be clickable in the terminal + // - We'll fail to show the file's content in the browser + // - We'll fail to show the code frame in the terminal + // - The "Open in Editor" button won't work + if ( + error.loc?.file && + rootFolder && + (!error.loc.file.startsWith(rootFolder.pathname) || !isAbsolute(error.loc.file)) + ) { error.loc.file = join(fileURLToPath(rootFolder), error.loc.file); } // If we don't have a frame, but we have a location let's try making up a frame for it - if (!error.frame && error.loc) { + if (error.loc && (!error.frame || !error.fullCode)) { try { const fileContents = fs.readFileSync(error.loc.file!, 'utf8'); - const frame = codeFrame(fileContents, error.loc); - error.frame = frame; + + if (!error.frame) { + const frame = codeFrame(fileContents, error.loc); + error.frame = frame; + } + + if (!error.fullCode) { + error.fullCode = fileContents; + } } catch {} } @@ -47,13 +66,16 @@ export function collectErrorMetadata(e: any, rootFolder?: URL | undefined): Erro error.hint = generateHint(e); }); - // If we received an array of errors and it's not from us, it should be from ESBuild, try to extract info for Vite to display + // If we received an array of errors and it's not from us, it's most likely from ESBuild, try to extract info for Vite to display + // NOTE: We still need to be defensive here, because it might not necessarily be from ESBuild, it's just fairly likely. if (!AggregateError.is(e) && Array.isArray((e as any).errors)) { (e.errors as EsbuildMessage[]).forEach((buildError, i) => { const { location, pluginName, text } = buildError; // ESBuild can give us a slightly better error message than the one in the error, so let's use it - err[i].message = text; + if (text) { + err[i].message = text; + } if (location) { err[i].loc = { file: location.file, line: location.line, column: location.column }; @@ -71,13 +93,17 @@ export function collectErrorMetadata(e: any, rootFolder?: URL | undefined): Erro } } - const possibleFilePath = err[i].pluginCode || err[i].id || location?.file; - if (possibleFilePath && !err[i].frame) { + const possibleFilePath = location?.file ?? err[i].id; + if (possibleFilePath && err[i].loc && (!err[i].frame || !err[i].fullCode)) { try { const fileContents = fs.readFileSync(possibleFilePath, 'utf8'); - err[i].frame = codeFrame(fileContents, { ...err[i].loc, file: possibleFilePath }); + if (!err[i].frame) { + err[i].frame = codeFrame(fileContents, { ...err[i].loc, file: possibleFilePath }); + } + + err[i].fullCode = fileContents; } catch { - // do nothing, code frame isn't that big a deal + err[i].fullCode = err[i].pluginCode; } } @@ -94,15 +120,17 @@ export function collectErrorMetadata(e: any, rootFolder?: URL | undefined): Erro } function generateHint(err: ErrorWithMetadata): string | undefined { + const commonBrowserAPIs = ['document', 'window']; + if (/Unknown file extension \"\.(jsx|vue|svelte|astro|css)\" for /.test(err.message)) { return 'You likely need to add this package to `vite.ssr.noExternal` in your astro config file.'; - } else if (err.toString().includes('document')) { + } else if (commonBrowserAPIs.some((api) => err.toString().includes(api))) { const hint = `Browser APIs are not available on the server. ${ err.loc?.file?.endsWith('.astro') - ? 'Move your code to a <script> tag outside of the frontmatter, so the code runs on the client' - : 'If the code is in a framework component, try to access these objects after rendering using lifecycle methods or use a `client:only` directive to make the component exclusively run on the client' + ? 'Move your code to a <script> tag outside of the frontmatter, so the code runs on the client.' + : 'If the code is in a framework component, try to access these objects after rendering using lifecycle methods or use a `client:only` directive to make the component exclusively run on the client.' } See https://docs.astro.build/en/guides/troubleshooting/#document-or-window-is-not-defined for more information. @@ -173,3 +201,26 @@ function cleanErrorStack(stack: string) { .map((l) => l.replace(/\/@fs\//g, '/')) .join('\n'); } + +/** + * Render a subset of Markdown to HTML or a CLI output + */ +export function renderErrorMarkdown(markdown: string, target: 'html' | 'cli') { + const linkRegex = /\[(.+)\]\((.+)\)/gm; + const boldRegex = /\*\*(.+)\*\*/gm; + const urlRegex = / (\b(https?|ftp):\/\/[-A-Z0-9+&@#\\/%?=~_|!:,.;]*[-A-Z0-9+&@#\\/%=~_|]) /gim; + const codeRegex = /`([^`]+)`/gim; + + if (target === 'html') { + return escape(markdown) + .replace(linkRegex, `<a href="$2" target="_blank">$1</a>`) + .replace(boldRegex, '<b>$1</b>') + .replace(urlRegex, ' <a href="$1" target="_blank">$1</a> ') + .replace(codeRegex, '<code>$1</code>'); + } else { + return markdown + .replace(linkRegex, (fullMatch, m1, m2) => `${bold(m1)} ${underline(m2)}`) + .replace(urlRegex, (fullMatch, m1) => ` ${underline(fullMatch.trim())} `) + .replace(boldRegex, (fullMatch, m1) => `${bold(m1)}`); + } +} diff --git a/packages/astro/src/core/errors/dev/vite.ts b/packages/astro/src/core/errors/dev/vite.ts index 88c947e5f..d0dcb1ea7 100644 --- a/packages/astro/src/core/errors/dev/vite.ts +++ b/packages/astro/src/core/errors/dev/vite.ts @@ -1,11 +1,12 @@ import * as fs from 'fs'; +import { getHighlighter } from 'shiki'; import { fileURLToPath } from 'url'; import { createLogger, type ErrorPayload, type Logger, type LogLevel } from 'vite'; import type { ModuleLoader } from '../../module-loader/index.js'; import { AstroErrorData } from '../errors-data.js'; import { type ErrorWithMetadata } from '../errors.js'; import { createSafeError } from '../utils.js'; -import { incompatPackageExp } from './utils.js'; +import { incompatPackageExp, renderErrorMarkdown } from './utils.js'; /** * Custom logger with better error reporting for incompatible packages @@ -46,6 +47,8 @@ export function enhanceViteSSRError(error: unknown, filePath?: URL, loader?: Mod if (/failed to load module for ssr:/.test(safeError.message)) { const importName = safeError.message.split('for ssr:').at(1)?.trim(); if (importName) { + safeError.title = AstroErrorData.FailedToLoadModuleSSR.title; + safeError.name = 'FailedToLoadModuleSSR'; safeError.message = AstroErrorData.FailedToLoadModuleSSR.message(importName); safeError.hint = AstroErrorData.FailedToLoadModuleSSR.hint; safeError.code = AstroErrorData.FailedToLoadModuleSSR.code; @@ -69,8 +72,10 @@ export function enhanceViteSSRError(error: unknown, filePath?: URL, loader?: Mod if (globPattern) { safeError.message = AstroErrorData.InvalidGlob.message(globPattern); + safeError.name = 'InvalidGlob'; safeError.hint = AstroErrorData.InvalidGlob.hint; safeError.code = AstroErrorData.InvalidGlob.code; + safeError.title = AstroErrorData.InvalidGlob.title; const line = lns.findIndex((ln) => ln.includes(globPattern)); @@ -90,31 +95,83 @@ export function enhanceViteSSRError(error: unknown, filePath?: URL, loader?: Mod return safeError; } +export interface AstroErrorPayload { + type: ErrorPayload['type']; + err: Omit<ErrorPayload['err'], 'loc'> & { + name?: string; + title?: string; + hint?: string; + docslink?: string; + highlightedCode?: string; + loc: { + file?: string; + line?: number; + column?: number; + }; + }; +} + /** * Generate a payload for Vite's error overlay */ -export function getViteErrorPayload(err: ErrorWithMetadata): ErrorPayload { +export async function getViteErrorPayload(err: ErrorWithMetadata): Promise<AstroErrorPayload> { let plugin = err.plugin; if (!plugin && err.hint) { plugin = 'astro'; } - const message = `${err.message}\n\n${err.hint ?? ''}`; - // Vite doesn't handle tabs correctly in its frames, so let's replace them with spaces - const frame = err.frame?.replace(/\t/g, ' '); + + const message = renderErrorMarkdown(err.message.trim(), 'html'); + const hint = err.hint ? renderErrorMarkdown(err.hint.trim(), 'html') : undefined; + + const hasDocs = + (err.type && + err.name && [ + 'AstroError', + 'AggregateError', + /* 'CompilerError' ,*/ + 'CSSError', + 'MarkdownError', + ]) || + ['FailedToLoadModuleSSR', 'InvalidGlob'].includes(err.name); + + const docslink = hasDocs + ? `https://docs.astro.build/en/reference/errors/${getKebabErrorName(err.name)}/` + : undefined; + + const highlighter = await getHighlighter({ theme: 'css-variables' }); + const highlightedCode = err.fullCode + ? highlighter.codeToHtml(err.fullCode, { + lang: err.loc?.file?.split('.').pop(), + lineOptions: err.loc?.line ? [{ line: err.loc.line, classes: ['error-line'] }] : undefined, + }) + : undefined; + return { type: 'error', err: { ...err, - frame: frame, + name: err.name, + type: err.type, + message, + hint, + frame: err.frame, + highlightedCode, + docslink, loc: { file: err.loc?.file, - // If we don't have a line and column, Vite won't make a clickable link, so let's fake 0:0 if we don't have a location - line: err.loc?.line ?? 0, - column: err.loc?.column ?? 0, + line: err.loc?.line, + column: err.loc?.column, }, plugin, - message: message.trim(), stack: err.stack, }, }; + + /** + * The docs has kebab-case urls for errors, so we need to convert the error name + * @param errorName + */ + function getKebabErrorName(errorName: string): string { + return errorName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + } } diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 8e8f41c43..62ccbfd68 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -109,9 +109,9 @@ export const AstroErrorData = defineErrors({ title: 'Invalid type returned by Astro page.', code: 3005, message: (route: string | undefined, returnedValue: string) => - `Route ${ + `Route \`${ route ? route : '' - } returned a \`${returnedValue}\`. Only a Response can be returned from Astro files.`, + }\` returned a \`${returnedValue}\`. Only a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) can be returned from Astro files.`, hint: 'See https://docs.astro.build/en/guides/server-side-rendering/#response for more information.', }, /** diff --git a/packages/astro/src/core/errors/errors.ts b/packages/astro/src/core/errors/errors.ts index 89bf3be6b..712528329 100644 --- a/packages/astro/src/core/errors/errors.ts +++ b/packages/astro/src/core/errors/errors.ts @@ -46,7 +46,7 @@ export class AstroError extends Error { const { code, name, title, message, stack, location, hint, frame } = props; this.errorCode = code; - if (name) { + if (name && name !== 'Error') { this.name = name; } else { // If we don't have a name, let's generate one from the code @@ -63,8 +63,6 @@ export class AstroError extends Error { public setErrorCode(errorCode: AstroErrorCodes) { this.errorCode = errorCode; - - this.name = getErrorDataByCode(this.errorCode)?.name ?? 'UnknownError'; } public setLocation(location: ErrorLocation): void { @@ -154,15 +152,17 @@ export class AggregateError extends AstroError { export interface ErrorWithMetadata { [name: string]: any; name: string; + title?: string; type?: ErrorTypes; message: string; stack: string; - code?: number; + errorCode?: number; hint?: string; id?: string; frame?: string; plugin?: string; pluginCode?: string; + fullCode?: string; loc?: { file?: string; line?: number; diff --git a/packages/astro/src/core/errors/overlay.ts b/packages/astro/src/core/errors/overlay.ts new file mode 100644 index 000000000..159a095f4 --- /dev/null +++ b/packages/astro/src/core/errors/overlay.ts @@ -0,0 +1,585 @@ +import type { AstroConfig } from '../../@types/astro'; +import type { AstroErrorPayload } from './dev/vite'; + +const style = /* css */ ` +* { + box-sizing: border-box; +} + +:host { + /** Needed so Playwright can find the element */ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 99999; + + /* Fonts */ + --font-normal: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", + "Helvetica Neue", Arial, sans-serif; + --font-monospace: ui-monospace, Menlo, Monaco, "Cascadia Mono", + "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", + "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace; + + /* Borders */ + --roundiness: 4px; + + /* Colors */ + --background: #ffffff; + --error-text: #ba1212; + --error-text-hover: #a10000; + --title-text: #090b11; + --box-background: #f3f4f7; + --box-background-hover: #dadbde; + --hint-text: #505d84; + --hint-text-hover: #37446b; + --border: #c3cadb; + --accent: #5f11a6; + --accent-hover: #792bc0; + --stack-text: #3d4663; + --misc-text: #6474a2; + + --houston-overlay: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 3.95%, + rgba(255, 255, 255, 0.0086472) 9.68%, + rgba(255, 255, 255, 0.03551) 15.4%, + rgba(255, 255, 255, 0.0816599) 21.13%, + rgba(255, 255, 255, 0.147411) 26.86%, + rgba(255, 255, 255, 0.231775) 32.58%, + rgba(255, 255, 255, 0.331884) 38.31%, + rgba(255, 255, 255, 0.442691) 44.03%, + rgba(255, 255, 255, 0.557309) 49.76%, + rgba(255, 255, 255, 0.668116) 55.48%, + rgba(255, 255, 255, 0.768225) 61.21%, + rgba(255, 255, 255, 0.852589) 66.93%, + rgba(255, 255, 255, 0.91834) 72.66%, + rgba(255, 255, 255, 0.96449) 78.38%, + rgba(255, 255, 255, 0.991353) 84.11%, + #ffffff 89.84% + ); + + /* Syntax Highlighting */ + --shiki-color-text: #000000; + --shiki-token-constant: #4ca48f; + --shiki-token-string: #9f722a; + --shiki-token-comment: #8490b5; + --shiki-token-keyword: var(--accent); + --shiki-token-parameter: #aa0000; + --shiki-token-function: #4ca48f; + --shiki-token-string-expression: #9f722a; + --shiki-token-punctuation: #ffffff; + --shiki-token-link: #ee0000; +} + +@media (prefers-color-scheme: dark) { + :host { + --background: #090b11; + --error-text: #f49090; + --error-text-hover: #ffaaaa; + --title-text: #ffffff; + --box-background: #141925; + --box-background-hover: #2e333f; + --hint-text: #a3acc8; + --hint-text-hover: #bdc6e2; + --border: #283044; + --accent: #c490f4; + --accent-hover: #deaaff; + --stack-text: #c3cadb; + --misc-text: #8490b5; + + --houston-overlay: linear-gradient( + 180deg, + rgba(9, 11, 17, 0) 3.95%, + rgba(9, 11, 17, 0.0086472) 9.68%, + rgba(9, 11, 17, 0.03551) 15.4%, + rgba(9, 11, 17, 0.0816599) 21.13%, + rgba(9, 11, 17, 0.147411) 26.86%, + rgba(9, 11, 17, 0.231775) 32.58%, + rgba(9, 11, 17, 0.331884) 38.31%, + rgba(9, 11, 17, 0.442691) 44.03%, + rgba(9, 11, 17, 0.557309) 49.76%, + rgba(9, 11, 17, 0.668116) 55.48%, + rgba(9, 11, 17, 0.768225) 61.21%, + rgba(9, 11, 17, 0.852589) 66.93%, + rgba(9, 11, 17, 0.91834) 72.66%, + rgba(9, 11, 17, 0.96449) 78.38%, + rgba(9, 11, 17, 0.991353) 84.11%, + #090b11 89.84% + ); + + /* Syntax Highlighting */ + --shiki-color-text: #ffffff; + --shiki-token-constant: #90f4e3; + --shiki-token-string: #f4cf90; + --shiki-token-comment: #8490b5; + --shiki-token-keyword: var(--accent); + --shiki-token-parameter: #aa0000; + --shiki-token-function: #90f4e3; + --shiki-token-string-expression: #f4cf90; + --shiki-token-punctuation: #ffffff; + --shiki-token-link: #ee0000; + } +} + +#backdrop { + font-family: var(--font-monospace); + position: fixed; + z-index: 99999; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--background); + overflow-y: auto; +} + +#layout { + max-width: min(100%, 1280px); + width: 1280px; + margin: 0 auto; + padding: 40px; + display: flex; + flex-direction: column; + gap: 24px; +} + +@media (max-width: 768px) { + #header { + padding: 12px; + margin-top: 12px; + } + + #layout { + padding: 0; + } +} + +@media (max-width: 1024px) { + #houston, + #houston-overlay { + display: none; + } +} + +#header { + position: relative; + margin-top: 48px; +} + +#header-left { + min-height: 63px; + display: flex; + flex-direction: column; + justify-content: end; +} + +#name { + font-size: 18px; + font-weight: normal; + line-height: 22px; + color: var(--error-text); + margin: 0; + padding: 0; +} + +#title { + font-size: 34px; + line-height: 41px; + font-weight: 600; + margin: 0; + padding: 0; + color: var(--title-text); + font-family: var(--font-normal); +} + +#houston { + position: absolute; + bottom: -50px; + right: 32px; + z-index: -50; + color: var(--error-text); +} + +#houston-overlay { + width: 175px; + height: 250px; + position: absolute; + bottom: -100px; + right: 32px; + z-index: -25; + background: var(--houston-overlay); +} + +#message-hints, +#stack, +#code { + border-radius: var(--roundiness); + background-color: var(--box-background); +} + +#message, +#hint { + display: flex; + padding: 16px; + gap: 16px; +} + +#message-content, +#hint-content { + white-space: pre-wrap; + line-height: 24px; + flex-grow: 1; +} + +#message { + color: var(--error-text); +} + +#message-content a { + color: var(--error-text); +} + +#message-content a:hover { + color: var(--error-text-hover); +} + +#hint { + color: var(--hint-text); + border-top: 1px solid var(--border); + display: none; +} + +#hint a { + color: var(--hint-text); +} + +#hint a:hover { + color: var(--hint-text-hover); +} + +#message-hints code { + font-family: var(--font-monospace); + background-color: var(--border); + padding: 4px; + border-radius: var(--roundiness); +} + +.link { + min-width: fit-content; + padding-right: 8px; + padding-top: 8px; +} + +.link button { + background: none; + border: none; + font-size: inherit; + font-family: inherit; +} + +.link a, .link button { + color: var(--accent); + text-decoration: none; + display: flex; + gap: 8px; +} + +.link a:hover, .link button:hover { + color: var(--accent-hover); + text-decoration: underline; + cursor: pointer; +} + +.link svg { + vertical-align: text-top; +} + +#code { + display: none; +} + +#code header { + padding: 24px; + display: flex; + justify-content: space-between; +} + +#code h2 { + font-family: var(--font-monospace); + color: var(--title-text); + font-size: 18px; + margin: 0; +} + +#code .link { + padding: 0; +} + +.shiki { + margin: 0; + border-top: 1px solid var(--border); + max-height: 17rem; + overflow: auto; +} + +.shiki code { + font-family: var(--font-monospace); + counter-reset: step; + counter-increment: step 0; + font-size: 14px; + line-height: 21px; + tab-size: 1; +} + +.shiki code .line:not(.error-caret)::before { + content: counter(step); + counter-increment: step; + width: 1rem; + margin-right: 16px; + display: inline-block; + text-align: right; + padding: 0 8px; + color: var(--misc-text); + border-right: solid 1px var(--border); +} + +.shiki code .line:first-child::before { + padding-top: 8px; +} + +.shiki code .line:last-child::before { + padding-bottom: 8px; +} + +.error-line { + background-color: #f4909026; + display: inline-block; + width: 100%; +} + +.error-caret { + margin-left: calc(33px + 1rem); + color: var(--error-text); +} + +#stack h2 { + color: var(--title-text); + font-family: var(--font-normal); + font-size: 22px; + margin: 0; + padding: 24px; + border-bottom: 1px solid var(--border); +} + +#stack-content { + font-size: 14px; + white-space: pre; + line-height: 21px; + overflow: auto; + padding: 24px; + color: var(--stack-text); +} +`; + +const overlayTemplate = /* html */ ` +<style> +${style.trim()} +</style> +<div id="backdrop"> + <div id="layout"> + <header id="header"> + <section id="header-left"> + <h2 id="name"></h2> + <h1 id="title">An error occurred.</h1> + </section> + <div id="houston-overlay"></div> + <div id="houston"> +<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="175" height="131" fill="none"><path fill="currentColor" d="M55.977 81.512c0 8.038-6.516 14.555-14.555 14.555S26.866 89.55 26.866 81.512c0-8.04 6.517-14.556 14.556-14.556 8.039 0 14.555 6.517 14.555 14.556Zm24.745-5.822c0-.804.651-1.456 1.455-1.456h11.645c.804 0 1.455.652 1.455 1.455v11.645c0 .804-.651 1.455-1.455 1.455H82.177a1.456 1.456 0 0 1-1.455-1.455V75.689Zm68.411 5.822c0 8.038-6.517 14.555-14.556 14.555-8.039 0-14.556-6.517-14.556-14.555 0-8.04 6.517-14.556 14.556-14.556 8.039 0 14.556 6.517 14.556 14.556Z"/><rect width="168.667" height="125" x="3.667" y="3" stroke="currentColor" stroke-width="4" rx="20.289"/></svg> + </div> + </header> + + <section id="message-hints"> + <section id="message"> + <span id="message-icon"> + <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="24" height="24" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 7v6m0 4.01.01-.011M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/></svg> + </span> + <div id="message-content"></div> + </section> + <section id="hint"> + <span id="hint-icon"> + <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="24" height="24" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m21 2-1 1M3 2l1 1m17 13-1-1M3 16l1-1m5 3h6m-5 3h4M12 3C8 3 5.952 4.95 6 8c.023 1.487.5 2.5 1.5 3.5S9 13 9 15h6c0-2 .5-2.5 1.5-3.5h0c1-1 1.477-2.013 1.5-3.5.048-3.05-2-5-6-5Z"/></svg> + </span> + <div id="hint-content"></div> + </section> + </section> + + <section id="code"> + <header> + <h2></h2> + </header> + <div id="code-content"></div> + </section> + + <section id="stack"> + <h2>Stack Trace</h2> + <div id="stack-content"></div> + </section> + </div> +</div> +`; + +const openNewWindowIcon = + /* html */ + '<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="16" height="16" fill="none"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 2h-4m4 0L8 8m6-6v4"/><path stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M14 8.667V12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h3.333"/></svg>'; + +// Make HTMLElement available in non-browser environments +const { HTMLElement = class {} as typeof globalThis.HTMLElement } = globalThis; +class ErrorOverlay extends HTMLElement { + root: ShadowRoot; + + constructor(err: AstroErrorPayload['err']) { + super(); + this.root = this.attachShadow({ mode: 'open' }); + this.root.innerHTML = overlayTemplate; + + this.text('#name', err.name); + this.text('#title', err.title); + this.text('#message-content', err.message, true); + + const hint = this.root.querySelector<HTMLElement>('#hint'); + if (hint && err.hint) { + this.text('#hint-content', err.hint, true); + hint.style.display = 'flex'; + } + + const docslink = this.root.querySelector<HTMLElement>('#message'); + if (docslink && err.docslink) { + docslink.appendChild(this.createLink(`See Docs Reference${openNewWindowIcon}`, err.docslink)); + } + + const code = this.root.querySelector<HTMLElement>('#code'); + if (code && err.loc.file) { + code.style.display = 'block'; + const codeHeader = code.querySelector<HTMLHeadingElement>('#code header'); + const codeContent = code.querySelector<HTMLDivElement>('#code-content'); + + if (codeHeader) { + const cleanFile = err.loc.file.split('/').slice(-2).join('/'); + const fileLocation = [cleanFile, err.loc.line, err.loc.column].filter(Boolean).join(':'); + const absoluteFileLocation = [err.loc.file, err.loc.line, err.loc.column] + .filter(Boolean) + .join(':'); + + const codeFile = codeHeader.getElementsByTagName('h2')[0]; + codeFile.textContent = fileLocation; + codeFile.title = absoluteFileLocation; + + const editorLink = this.createLink(`Open in editor${openNewWindowIcon}`, undefined); + editorLink.onclick = () => { + fetch('/__open-in-editor?file=' + encodeURIComponent(absoluteFileLocation)); + }; + + codeHeader.appendChild(editorLink); + } + + if (codeContent && err.highlightedCode) { + codeContent.innerHTML = err.highlightedCode; + + window.requestAnimationFrame(() => { + // NOTE: This cannot be `codeContent.querySelector` because `codeContent` still contain the old HTML + const errorLine = this.root.querySelector<HTMLSpanElement>('.error-line'); + + if (errorLine) { + if (errorLine.parentElement && errorLine.parentElement.parentElement) { + errorLine.parentElement.parentElement.scrollTop = + errorLine.offsetTop - errorLine.parentElement.parentElement.offsetTop - 8; + } + + // Add an empty line below the error line so we can show a caret under the error + if (err.loc.column) { + errorLine.insertAdjacentHTML( + 'afterend', + `\n<span class="line error-caret"><span style="padding-left:${ + err.loc.column - 1 + }ch;">^</span></span>` + ); + } + } + }); + } + } + + this.text('#stack-content', err.stack); + } + + text(selector: string, text: string | undefined, html = false): void { + if (!text) { + return; + } + + const el = this.root.querySelector(selector); + + if (el) { + if (!html) { + el.textContent = text.trim(); + } else { + el.innerHTML = text.trim(); + } + } + } + + createLink(text: string, href: string | undefined): HTMLDivElement { + const linkContainer = document.createElement('div'); + const linkElement = href ? document.createElement('a') : document.createElement('button'); + linkElement.innerHTML = text; + + if (href && linkElement instanceof HTMLAnchorElement) { + linkElement.href = href; + linkElement.target = '_blank'; + } + + linkContainer.appendChild(linkElement); + linkContainer.className = 'link'; + + return linkContainer; + } + + close(): void { + this.parentNode?.removeChild(this); + } +} + +function getOverlayCode() { + return ` + const overlayTemplate = \`${overlayTemplate}\`; + const openNewWindowIcon = \`${openNewWindowIcon}\`; + ${ErrorOverlay.toString()} + `; +} + +export function patchOverlay(code: string, config: AstroConfig) { + if(config.experimentalErrorOverlay) { + return code.replace('class ErrorOverlay', getOverlayCode() + '\nclass ViteErrorOverlay'); + } else { + // Legacy overlay + return ( + code + // Transform links in the message to clickable links + .replace( + "this.text('.message-body', message.trim());", + `const urlPattern = /(\\b(https?|ftp):\\/\\/[-A-Z0-9+&@#\\/%?=~_|!:,.;]*[-A-Z0-9+&@#\\/%=~_|])/gim; + function escapeHtml(unsafe){return unsafe.replace(/</g, "<").replace(/>/g, ">");} + const escapedMessage = escapeHtml(message); + this.root.querySelector(".message-body").innerHTML = escapedMessage.trim().replace(urlPattern, '<a href="$1" target="_blank">$1</a>');` + ) + .replace('</style>', '.message-body a {\n color: #ededed;\n}\n</style>') + // Hide `.tip` in Vite's ErrorOverlay + .replace(/\.tip \{[^}]*\}/gm, '.tip {\n display: none;\n}') + // Replace [vite] messages with [astro] + .replace(/\[vite\]/g, '[astro]') + ) + } +} diff --git a/packages/astro/src/core/messages.ts b/packages/astro/src/core/messages.ts index 3bfcbb61a..efac24f5e 100644 --- a/packages/astro/src/core/messages.ts +++ b/packages/astro/src/core/messages.ts @@ -18,7 +18,8 @@ import type { AddressInfo } from 'net'; import os from 'os'; import { ResolvedServerUrls } from 'vite'; import { ZodError } from 'zod'; -import { ErrorWithMetadata } from './errors/index.js'; +import { renderErrorMarkdown } from './errors/dev/utils.js'; +import { AstroError, CompilerError, ErrorWithMetadata } from './errors/index.js'; import { removeTrailingForwardSlash } from './path.js'; import { emoji, getLocalAddress, padMultilineString } from './util.js'; @@ -254,10 +255,18 @@ export function formatConfigErrorMessage(err: ZodError) { } export function formatErrorMessage(err: ErrorWithMetadata, args: string[] = []): string { - args.push(`${bgRed(black(` error `))}${red(bold(padMultilineString(err.message)))}`); + const isOurError = AstroError.is(err) || CompilerError.is(err); + + args.push( + `${bgRed(black(` error `))}${red( + padMultilineString(isOurError ? renderErrorMarkdown(err.message, 'cli') : err.message) + )}` + ); if (err.hint) { args.push(` ${bold('Hint:')}`); - args.push(yellow(padMultilineString(err.hint, 4))); + args.push( + yellow(padMultilineString(isOurError ? renderErrorMarkdown(err.hint, 'cli') : err.hint, 4)) + ); } if (err.id || err.loc?.file) { args.push(` ${bold('File:')}`); @@ -271,7 +280,7 @@ export function formatErrorMessage(err: ErrorWithMetadata, args: string[] = []): } if (err.frame) { args.push(` ${bold('Code:')}`); - args.push(red(padMultilineString(err.frame, 4))); + args.push(red(padMultilineString(err.frame.trim(), 4))); } if (args.length === 1 && err.stack) { args.push(dim(err.stack)); diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 589a74e74..a880bf36f 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -9,6 +9,7 @@ import { createRouteManifest } from '../core/routing/index.js'; import { baseMiddleware } from './base.js'; import { createController } from './controller.js'; import { handleRequest } from './request.js'; +import { patchOverlay } from '../core/errors/overlay.js'; export interface AstroPluginOptions { settings: AstroSettings; @@ -59,27 +60,12 @@ export default function createVitePluginAstroServer({ }); }; }, - // HACK: Manually replace code in Vite's overlay to fit it to our needs - // In the future, we'll instead take over the overlay entirely, which should be safer and cleaner transform(code, id, opts = {}) { if (opts.ssr) return; if (!id.includes('vite/dist/client/client.mjs')) return; - return ( - code - // Transform links in the message to clickable links - .replace( - "this.text('.message-body', message.trim());", - `const urlPattern = /(\\b(https?|ftp):\\/\\/[-A-Z0-9+&@#\\/%?=~_|!:,.;]*[-A-Z0-9+&@#\\/%=~_|])/gim; - function escapeHtml(unsafe){return unsafe.replace(/</g, "<").replace(/>/g, ">");} - const escapedMessage = escapeHtml(message); - this.root.querySelector(".message-body").innerHTML = escapedMessage.trim().replace(urlPattern, '<a href="$1" target="_blank">$1</a>');` - ) - .replace('</style>', '.message-body a {\n color: #ededed;\n}\n</style>') - // Hide `.tip` in Vite's ErrorOverlay - .replace(/\.tip \{[^}]*\}/gm, '.tip {\n display: none;\n}') - // Replace [vite] messages with [astro] - .replace(/\[vite\]/g, '[astro]') - ); + + // Replace the Vite overlay with ours + return patchOverlay(code, settings.config); }, }; } diff --git a/packages/astro/src/vite-plugin-astro-server/response.ts b/packages/astro/src/vite-plugin-astro-server/response.ts index 6a8d3a608..911ccf413 100644 --- a/packages/astro/src/vite-plugin-astro-server/response.ts +++ b/packages/astro/src/vite-plugin-astro-server/response.ts @@ -28,7 +28,9 @@ export async function handle500Response( res: http.ServerResponse, err: ErrorWithMetadata ) { - res.on('close', () => setTimeout(() => loader.webSocketSend(getViteErrorPayload(err)), 200)); + res.on('close', async () => + setTimeout(async () => loader.webSocketSend(await getViteErrorPayload(err)), 200) + ); if (res.headersSent) { res.write(`<script type="module" src="/@vite/client"></script>`); res.end(); |