Add a new error overlay (#5495)

* Add new overlay

* Fix CSS errors missing the proper stacktrace

* Fix names not working in some cases

* Add changeset

* Fix Playwright not detecting the overlay

* Update E2E test

* Fix tests

* Small refactor, fix syntax highlight on light mode, fix code element showing even with no code

* Simplier injection

* Add Markdown support to CLI reporting

* Fix not being able to navigate with the keyboard to the open in editor link

* aria-hide some svgs (#5508)

we should also make the "open in editor" button a button, not a link, if we are using JS for interactions

* Implement close method so Vite can close the overlay when needed

* Fix filepaths not being absolute when coming from node_modules for errors

* Fix multi line errors with indentation not showing correctly

* Fix entire page being scrolled to the error line in certain cases

* Update docs links

* Put the new error overlay behind a flag

* add flag for e2e tests

Co-authored-by: Caleb Jasik <calebjasik@jasik.xyz>
Co-authored-by: Matthew Phillips <matthew@skypack.dev>
This commit is contained in:
Erika 2022-12-14 11:27:15 -04:00 committed by GitHub
parent 6b156dd3b4
commit 31ec847972
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 800 additions and 73 deletions

View file

@ -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.

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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([

View file

@ -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);

View file

@ -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 };
}
/**

View file

@ -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. */

View file

@ -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,
});
}

View file

@ -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;
}

View file

@ -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 {

View file

@ -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)}`);
}
}

View file

@ -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();
}
}

View file

@ -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.',
},
/**

View file

@ -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;

View file

@ -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, "&lt;").replace(/>/g, "&gt;");}
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]')
)
}
}

View file

@ -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));

View file

@ -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, "&lt;").replace(/>/g, "&gt;");}
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);
},
};
}

View file

@ -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();