From dbf0fcb3feb46fdd981f2fdd948281c07b136991 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Tue, 27 Oct 2026 13:44:43 +0800 Subject: [PATCH 1/5] feat(pages): refresh default 404 and 500 pages --- packages/void/src/assets.d.ts | 9 ++ packages/void/src/css.d.ts | 4 - .../void/src/runtime/error-pages/404.html | 43 ++++++ .../void/src/runtime/error-pages/500.html | 34 +++++ .../void/src/runtime/error-pages/style.css | 127 ++++++++++++++++++ packages/void/src/runtime/routing-errors.ts | 98 ++++---------- .../void/test/integration/error-pages.test.ts | 79 +++++++++++ .../void/test/unit/routing-runtime.test.ts | 34 +++++ packages/void/vite.config.ts | 4 +- 9 files changed, 353 insertions(+), 79 deletions(-) create mode 100644 packages/void/src/assets.d.ts delete mode 100644 packages/void/src/css.d.ts create mode 100644 packages/void/src/runtime/error-pages/404.html create mode 100644 packages/void/src/runtime/error-pages/500.html create mode 100644 packages/void/src/runtime/error-pages/style.css create mode 100644 packages/void/test/integration/error-pages.test.ts diff --git a/packages/void/src/assets.d.ts b/packages/void/src/assets.d.ts new file mode 100644 index 000000000..48cd4d75c --- /dev/null +++ b/packages/void/src/assets.d.ts @@ -0,0 +1,9 @@ +declare module '*.css?raw' { + const content: string; + export default content; +} + +declare module '*.html?raw' { + const content: string; + export default content; +} diff --git a/packages/void/src/css.d.ts b/packages/void/src/css.d.ts deleted file mode 100644 index 4f8dea640..000000000 --- a/packages/void/src/css.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*.css?raw' { - const content: string; - export default content; -} diff --git a/packages/void/src/runtime/error-pages/404.html b/packages/void/src/runtime/error-pages/404.html new file mode 100644 index 000000000..74d78f2dd --- /dev/null +++ b/packages/void/src/runtime/error-pages/404.html @@ -0,0 +1,43 @@ + + + + + + 404: Not Found + + + +
+

404 · Not found

+

Page not found

+

+ The page may have moved, or the address may be incorrect. +

+ +
+ + + diff --git a/packages/void/src/runtime/error-pages/500.html b/packages/void/src/runtime/error-pages/500.html new file mode 100644 index 000000000..615175c0c --- /dev/null +++ b/packages/void/src/runtime/error-pages/500.html @@ -0,0 +1,34 @@ + + + + + + 500: Internal Server Error + + + +
+

500 · Internal server error

+

Something went wrong

+

This page couldn’t load. Please try again in a moment.

+ + + Reload page + +
+ + diff --git a/packages/void/src/runtime/error-pages/style.css b/packages/void/src/runtime/error-pages/style.css new file mode 100644 index 000000000..7ed7bc72f --- /dev/null +++ b/packages/void/src/runtime/error-pages/style.css @@ -0,0 +1,127 @@ +:root { + color-scheme: light dark; + background: light-dark(#faf9f7, #151418); +} + +.void-error-page { + --void-error-text: light-dark(#242129, #f3f1f5); + --void-error-muted: light-dark(#716b77, #aaa4b1); + --void-error-border: light-dark(#d0ccd6, #4d4854); + --void-error-hover: light-dark(#16171d, #ffffff); + --void-error-hover-text: light-dark(#ffffff, #16171d); + box-sizing: border-box; + display: grid; + place-items: center; + min-block-size: 100vh; + min-block-size: 100dvh; + margin: 0; + padding: 48px 24px; + color: var(--void-error-text); + background: light-dark(#faf9f7, #151418); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + font-size: 15px; + line-height: 1.65; + letter-spacing: -0.01em; + -webkit-font-smoothing: antialiased; +} + +.void-error-content { + inline-size: 100%; + max-inline-size: 400px; + min-inline-size: 0; +} + +.void-error-status { + margin: 0 0 22px; + color: var(--void-error-muted); + font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; + font-size: 12px; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.3px; + text-transform: uppercase; +} + +.void-error-content h1 { + margin: 0; + color: var(--void-error-text); + font-family: inherit; + font-size: 34px; + font-weight: 500; + line-height: 1.15; + letter-spacing: -0.045em; + text-wrap: balance; +} + +.void-error-description { + max-inline-size: 37ch; + margin: 16px 0 0; + color: var(--void-error-muted); + font-size: 15px; + font-weight: 400; + line-height: 1.65; + text-wrap: pretty; +} + +.void-error-action { + position: relative; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-block-size: 32px; + margin: 24px 0 0; + padding: 7px 12px; + border: 1px solid var(--void-error-border); + border-radius: 6px; + color: var(--void-error-text); + background: transparent; + font-family: inherit; + font-size: 12px; + font-weight: 500; + line-height: 16px; + letter-spacing: -0.01em; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +.void-error-action::after { + position: absolute; + inset: -6px -4px; + content: ''; +} + +.void-error-action:hover { + border-color: var(--void-error-hover); + background: var(--void-error-hover); + color: var(--void-error-hover-text); +} + +.void-error-action:focus-visible { + outline: 2px solid var(--void-error-muted); + outline-offset: 4px; +} + +.void-error-action[hidden] { + display: inline-flex; + visibility: hidden; +} + +.void-error-action svg { + flex-shrink: 0; +} + +@supports (corner-shape: squircle) { + .void-error-action { + border-radius: 12px; + corner-shape: squircle; + } +} + +@media (max-width: 398px) { + .void-error-content h1 { + font-size: 30px; + } +} diff --git a/packages/void/src/runtime/routing-errors.ts b/packages/void/src/runtime/routing-errors.ts index 060c41714..c3febda40 100644 --- a/packages/void/src/runtime/routing-errors.ts +++ b/packages/void/src/runtime/routing-errors.ts @@ -1,4 +1,10 @@ import type { Context } from 'hono'; +import notFoundPage from './error-pages/404.html?raw'; +import serverErrorPage from './error-pages/500.html?raw'; +import errorPageCss from './error-pages/style.css?raw'; + +type ErrorPageStatus = 404 | 500; +const errorPages = { 404: notFoundPage, 500: serverErrorPage }; type StackFrame = { functionName: string; @@ -12,10 +18,15 @@ type StackFrame = { type ErrorDetails = { name: string; message: string; stack: string; frames: Array }; export type ErrorRendererOptions = { dev?: boolean; projectRoot: string; homeDir: string }; export type ErrorRenderer = { - createResponse: (request: Request, status: number, title: string, error?: unknown) => Response; + createResponse: ( + request: Request, + status: ErrorPageStatus, + title: string, + error?: unknown, + ) => Response; serveCustomPage: ( context: Context, - status: number, + status: ErrorPageStatus, title: string, error?: unknown, ) => Promise; @@ -567,80 +578,21 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere return /<\/head\s*>/i.test(html) ? html.replace(/<\/head\s*>/i, css + '') : css + html; } - function renderErrorPage(status: number, title: string, error?: unknown): string { - const heading = status + ': ' + title; - const prefix = status + ': '; - const overlay = - dev && status === 500 && error ? renderDevErrorOverlay(status, title, error) : ''; - return ` - - - - - ${escapeHtml(heading)} - ${getErrorAssetCss(error)} - - - -
-

${escapeHtml(prefix)}${escapeHtml(title)}

-
- ${overlay} - -`; + function renderErrorPage(status: ErrorPageStatus, title: string, error?: unknown): string { + // Inline the shared stylesheet so fallback pages do not depend on asset serving. + const page = errorPages[status].replace( + '', + ``, + ); + const html = injectErrorAssetCss(page, getErrorAssetCss(error)); + return dev && status === 500 && error + ? injectDevErrorOverlay(html, renderDevErrorOverlay(status, title, error)) + : html; } function createErrorResponse( request: Request, - status: number, + status: ErrorPageStatus, title: string, error?: unknown, ): Response { @@ -655,7 +607,7 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere async function serveCustomPage( c: Context, - status: number, + status: ErrorPageStatus, fallbackText: string, error?: unknown, ): Promise { diff --git a/packages/void/test/integration/error-pages.test.ts b/packages/void/test/integration/error-pages.test.ts new file mode 100644 index 000000000..b7e502385 --- /dev/null +++ b/packages/void/test/integration/error-pages.test.ts @@ -0,0 +1,79 @@ +import { chromium, type Browser } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; + +// Exercise the bundle without loading its global declarations alongside source declarations. +const { createErrorRenderer } = (await import( + new URL('../../dist/runtime/routing.mjs', import.meta.url).href +)) as typeof import('../../src/runtime/routing-errors.ts'); + +const errors = createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }); +let browser: Browser; + +beforeAll(async () => { + browser = await chromium.launch(); +}); + +afterAll(async () => { + await browser?.close(); +}); + +describe('default error page actions', () => { + it('shows Go back only when a previous entry exists and returns to that entry', async () => { + const page = await browser.newPage(); + page.setDefaultTimeout(10_000); + await page.route('https://app.test/**', async (route) => { + if (new URL(route.request().url()).pathname === '/article') { + return route.fulfill({ + contentType: 'text/html', + body: 'Missing page', + }); + } + const response = errors.createResponse(new Request(route.request().url()), 404, 'Not Found'); + return route.fulfill({ + status: response.status, + contentType: 'text/html', + body: await response.text(), + }); + }); + try { + await page.goto('https://app.test/missing'); + const back = page.getByRole('button', { name: 'Go back', includeHidden: true }); + expect(await back.isVisible()).toBe(false); + await page.goto('https://app.test/article'); + await page.getByRole('link', { name: 'Missing page' }).click(); + expect(await back.isVisible()).toBe(true); + await back.click(); + await page.waitForURL('https://app.test/article'); + } finally { + await page.close(); + } + }); + + it('retries the current URL including its query without JavaScript or extra assets', async () => { + const context = await browser.newContext({ javaScriptEnabled: false }); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const requests: Array = []; + const url = 'https://app.test/account?view=activity'; + await page.route('https://app.test/**', async (route) => { + requests.push(route.request().url()); + if (requests.length > 1) { + return route.fulfill({ contentType: 'text/html', body: '

Recovered

' }); + } + const response = errors.createResponse(new Request(url), 500, 'Internal Server Error'); + return route.fulfill({ + status: response.status, + contentType: 'text/html', + body: await response.text(), + }); + }); + try { + await page.goto(url); + await page.getByRole('link', { name: 'Reload page' }).click(); + await page.getByRole('heading', { name: 'Recovered' }).waitFor(); + expect(requests).toEqual([url, url]); + } finally { + await context.close(); + } + }); +}); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 2fca4b381..0cab495ef 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -690,6 +690,40 @@ describe('typed Pages and SSR execution', () => { }); describe('typed error rendering', () => { + it.each([ + [404, 'Not Found'], + [500, 'Internal Server Error'], + ] as const)( + 'serves an HTML %s page when custom assets are unavailable', + async (status, title) => { + const app = new Hono(); + const errors = createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }); + app.get('/page', (c) => errors.serveCustomPage(c, status, title)); + const response = await app.fetch(new Request('https://app.test/page'), { + ASSETS: { + fetch: async () => { + throw new Error('assets unavailable'); + }, + }, + }); + expect(response.status).toBe(status); + expect(response.headers.get('content-type')).toContain('text/html'); + expect(await response.text()).toContain(String(status)); + }, + ); + it.each([404, 500] as const)('serves a custom %s page unchanged', async (status) => { + const app = new Hono(); + const errors = createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }); + app.get('/page', (c) => errors.serveCustomPage(c, status, 'Custom error')); + const body = 'Custom error page'; + const fetch = async (request: Request) => + new URL(request.url).pathname === `/${status}.html` + ? new Response(body) + : new Response('Not Found', { status: 404 }); + const response = await app.fetch(new Request('https://app.test/page'), { ASSETS: { fetch } }); + expect(response.status).toBe(status); + expect(await response.text()).toBe(body); + }); it('escapes exceptions, renders causes and editor links in development and suppresses details in production', async () => { const error = new Error('', { cause: new Error('nested failure') }); error.stack = 'Error: private\n at render (/project/pages/index.ts:12:3)'; diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index 90217727b..c30a045e9 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -147,9 +147,9 @@ export default defineConfig({ clean: true, plugins: [ { - name: 'void:raw-css', + name: 'void:raw-assets', load: { - filter: { id: /\.css\?raw$/ }, + filter: { id: /\.(?:css|html)\?raw$/ }, handler(id) { const file = id.slice(0, -'?raw'.length); this.addWatchFile(file); From 1c8808a6db994817fea0fc5225cdc8da707d98c9 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Tue, 27 Oct 2026 17:58:12 +0800 Subject: [PATCH 2/5] feat(pages): add error page variants and static fallback --- docs/guide/ssg.md | 2 + packages/void/src/assets.d.ts | 5 + packages/void/src/prerender.ts | 17 +- .../void/src/runtime/error-pages/404.html | 59 ++-- .../void/src/runtime/error-pages/500.html | 48 +-- .../void/src/runtime/error-pages/index.ts | 12 + .../void/src/runtime/error-pages/script.js | 79 +++++ .../void/src/runtime/error-pages/style.css | 306 +++++++++++++----- packages/void/src/runtime/routing-errors.ts | 11 +- .../void/test/integration/error-pages.test.ts | 38 +++ .../test/integration/static-pages.test.ts | 6 + packages/void/test/unit/prerender.test.ts | 28 +- packages/void/vite.config.ts | 2 +- 13 files changed, 470 insertions(+), 143 deletions(-) create mode 100644 packages/void/src/runtime/error-pages/index.ts create mode 100644 packages/void/src/runtime/error-pages/script.js diff --git a/docs/guide/ssg.md b/docs/guide/ssg.md index 1fd466de5..46e34216a 100644 --- a/docs/guide/ssg.md +++ b/docs/guide/ssg.md @@ -19,6 +19,8 @@ When `output` is `"static"`: - Dynamic pages without `getPrerenderPaths()` are implicitly not prerendered (the paths aren't known at build time). - The build output is self-contained and works for direct Cloudflare deployment, self-hosting, or `void deploy`. +Static output includes a default `404.html` for missing pages. Add `public/404.html` to use your own error page instead. When using another static host, configure it to serve this file with a 404 status for missing URLs. + ## Per-page overrides Individual pages can opt out of prerendering: diff --git a/packages/void/src/assets.d.ts b/packages/void/src/assets.d.ts index 48cd4d75c..a2cb5c3a0 100644 --- a/packages/void/src/assets.d.ts +++ b/packages/void/src/assets.d.ts @@ -7,3 +7,8 @@ declare module '*.html?raw' { const content: string; export default content; } + +declare module '*.js?raw' { + const content: string; + export default content; +} diff --git a/packages/void/src/prerender.ts b/packages/void/src/prerender.ts index 892766bda..906c82c29 100644 --- a/packages/void/src/prerender.ts +++ b/packages/void/src/prerender.ts @@ -15,6 +15,7 @@ import { type PrerenderResult, } from './prerender-render.ts'; export type { PrerenderResult } from './prerender-render.ts'; +import { renderDefaultErrorPage } from './runtime/error-pages/index.ts'; import { scopedAccessCredentials } from './shared/cf-access.ts'; import { collectFiles } from './shared/collect-files.ts'; import { stripInternalEnvKeys } from './shared/env.ts'; @@ -38,6 +39,15 @@ export type PrerenderRunResult = PrerenderResult & { paths: Array; }; +export function writeDefaultNotFoundPage(clientDir: string): void { + // Preserve a public/404.html or a page explicitly prerendered at /404. + const path = join(clientDir, '404.html'); + if (!existsSync(path)) { + mkdirSync(clientDir, { recursive: true }); + writeFileSync(path, renderDefaultErrorPage(404)); + } +} + function normalizeWorkerModulePath(path: string): string { return path.replace(/\\/g, '/').replace(/^\.\//, ''); } @@ -339,10 +349,6 @@ export async function prerenderPages(options: { const runner = await createCloudflarePrerenderRunner(options); try { const paths = await runner.collectPaths(); - if (paths.length === 0) { - return { paths, rendered: [], failed: [] }; - } - const result = await renderPrerenderPaths( paths, options.clientDir, @@ -356,6 +362,7 @@ export async function prerenderPages(options: { } }, ); + writeDefaultNotFoundPage(options.clientDir); return { paths, ...result }; } finally { await runner.close(); @@ -481,6 +488,8 @@ export async function prerenderPagesNode(options: { clientDir: options.clientDir, }); + writeDefaultNotFoundPage(options.clientDir); + for (const path of result.rendered) { voidLog(`prerender ${pc.blue(outputFileForPath(path))}`); } diff --git a/packages/void/src/runtime/error-pages/404.html b/packages/void/src/runtime/error-pages/404.html index 74d78f2dd..cc5f12025 100644 --- a/packages/void/src/runtime/error-pages/404.html +++ b/packages/void/src/runtime/error-pages/404.html @@ -5,39 +5,32 @@ 404: Not Found + - -
-

404 · Not found

-

Page not found

-

- The page may have moved, or the address may be incorrect. -

- -
- + +
+ + + + +
+ + + + +

+ 404Lost in the void +

+

This page doesn't exist

+ +
+ + + + + +
diff --git a/packages/void/src/runtime/error-pages/500.html b/packages/void/src/runtime/error-pages/500.html index 615175c0c..1aa5791ac 100644 --- a/packages/void/src/runtime/error-pages/500.html +++ b/packages/void/src/runtime/error-pages/500.html @@ -5,30 +5,32 @@ 500: Internal Server Error + - -
-

500 · Internal server error

-

Something went wrong

-

This page couldn’t load. Please try again in a moment.

- -
+ + + + + + diff --git a/packages/void/src/runtime/error-pages/index.ts b/packages/void/src/runtime/error-pages/index.ts new file mode 100644 index 000000000..06f6c361c --- /dev/null +++ b/packages/void/src/runtime/error-pages/index.ts @@ -0,0 +1,12 @@ +import notFoundPage from './404.html?raw'; +import serverErrorPage from './500.html?raw'; +import script from './script.js?raw'; +import stylesheet from './style.css?raw'; + +const pages = { 404: notFoundPage, 500: serverErrorPage }; + +export function renderDefaultErrorPage(status: 404 | 500): string { + return pages[status] + .replace('', () => ``) + .replace('', () => ``); +} diff --git a/packages/void/src/runtime/error-pages/script.js b/packages/void/src/runtime/error-pages/script.js new file mode 100644 index 000000000..6d1a39fc3 --- /dev/null +++ b/packages/void/src/runtime/error-pages/script.js @@ -0,0 +1,79 @@ +(() => { + const designs = ['signal', 'grid', 'fold']; + document.documentElement.dataset.design = designs[Math.floor(Math.random() * designs.length)]; + + function setup() { + const back = document.getElementById('void-error-back'); + const canGoBack = window.navigation ? window.navigation.canGoBack : window.history.length > 1; + if (back && canGoBack) { + back.hidden = false; + back.addEventListener('click', () => window.history.back()); + } + + if (document.documentElement.dataset.design !== 'signal') { + return; + } + const canvas = document.querySelector('.error-texture'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + function draw() { + const { width, height } = canvas.getBoundingClientRect(); + const ratio = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + const colors = getComputedStyle(canvas); + const chars = ['·', '/', '0', '1', '+', '[', ']', '—']; + ctx.font = '8px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = colors.color; + + for (let y = 8, row = 0; y < height; y += 12, row++) { + for (let x = 8, col = 0; x < width; x += 9, col++) { + const distance = Math.hypot( + (x - width / 2) / (width * 0.47), + (y - height * 0.48) / (height * 0.55), + ); + const seed = (Math.imul(row + 11, 61) ^ Math.imul(col + 7, 137)) >>> 0; + const core = Math.max(0, Math.min(1, (distance - 0.28) / 0.55)); + const opacity = core * (0.15 + (seed % 7) * 0.025) * 0.75; + if (seed % 17 === 0 || opacity < 0.015) { + continue; + } + ctx.globalAlpha = opacity; + ctx.fillText(chars[(seed >>> 3) % chars.length], x, y); + } + } + + ctx.globalAlpha = 1; + ctx.strokeStyle = colors.borderTopColor; + for (const [x, y] of [ + [28, 28], + [width - 28, 28], + [28, height - 28], + [width - 28, height - 28], + ]) { + ctx.beginPath(); + ctx.moveTo(x - 4, y); + ctx.lineTo(x + 4, y); + ctx.moveTo(x, y - 4); + ctx.lineTo(x, y + 4); + ctx.stroke(); + } + } + + new ResizeObserver(draw).observe(canvas); + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw); + draw(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setup, { once: true }); + } else { + setup(); + } +})(); diff --git a/packages/void/src/runtime/error-pages/style.css b/packages/void/src/runtime/error-pages/style.css index 7ed7bc72f..01610e64b 100644 --- a/packages/void/src/runtime/error-pages/style.css +++ b/packages/void/src/runtime/error-pages/style.css @@ -1,127 +1,289 @@ :root { color-scheme: light dark; - background: light-dark(#faf9f7, #151418); + --background: light-dark(#fff, #16171d); + --foreground: light-dark(#16171d, #f6f5f8); + --muted: light-dark(#6e6874, #a8a2ae); + --line: light-dark(#e5e4e7, #36323c); + --ink: light-dark(#4e4856, #c3bdcd); + --corner: light-dark(#e5e4e7, #3b3440); + background: var(--background); + color: var(--foreground); + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + -webkit-font-smoothing: antialiased; } -.void-error-page { - --void-error-text: light-dark(#242129, #f3f1f5); - --void-error-muted: light-dark(#716b77, #aaa4b1); - --void-error-border: light-dark(#d0ccd6, #4d4854); - --void-error-hover: light-dark(#16171d, #ffffff); - --void-error-hover-text: light-dark(#ffffff, #16171d); +* { box-sizing: border-box; - display: grid; - place-items: center; - min-block-size: 100vh; - min-block-size: 100dvh; +} + +body { margin: 0; - padding: 48px 24px; - color: var(--void-error-text); - background: light-dark(#faf9f7, #151418); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - font-size: 15px; - line-height: 1.65; - letter-spacing: -0.01em; - -webkit-font-smoothing: antialiased; } -.void-error-content { - inline-size: 100%; - max-inline-size: 400px; - min-inline-size: 0; +.error-page { + position: relative; + isolation: isolate; + display: flex; + align-items: center; + justify-content: center; + min-block-size: 100svh; + overflow: hidden; } -.void-error-status { - margin: 0 0 22px; - color: var(--void-error-muted); - font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; - font-size: 12px; - font-weight: 400; - line-height: 16px; - letter-spacing: 0.3px; - text-transform: uppercase; +.error-content { + min-inline-size: 0; + padding: 32px 24px; + text-align: center; } -.void-error-content h1 { +.error-content h1 { + display: flex; + flex-direction: column; + gap: 18px; margin: 0; - color: var(--void-error-text); - font-family: inherit; - font-size: 34px; font-weight: 500; - line-height: 1.15; +} + +.error-code { + font-size: 64px; + line-height: 1; letter-spacing: -0.045em; - text-wrap: balance; } -.void-error-description { - max-inline-size: 37ch; - margin: 16px 0 0; - color: var(--void-error-muted); +.error-title { font-size: 15px; - font-weight: 400; - line-height: 1.65; - text-wrap: pretty; + line-height: 1.4; + letter-spacing: -0.02em; + text-wrap: balance; +} + +.error-description { + margin: 6px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.5; + letter-spacing: -0.01em; } -.void-error-action { +.error-action { position: relative; - box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-block-size: 32px; - margin: 24px 0 0; - padding: 7px 12px; - border: 1px solid var(--void-error-border); + margin-block-start: 20px; + padding: 6px 12px; + border: 1px solid var(--line); border-radius: 6px; - color: var(--void-error-text); - background: transparent; - font-family: inherit; - font-size: 12px; - font-weight: 500; - line-height: 16px; + background: var(--background); + color: var(--foreground); + font: + 500 12px/18px system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; letter-spacing: -0.01em; text-decoration: none; white-space: nowrap; cursor: pointer; } -.void-error-action::after { +.error-action::after { position: absolute; - inset: -6px -4px; + inset: -6px -3px; content: ''; } -.void-error-action:hover { - border-color: var(--void-error-hover); - background: var(--void-error-hover); - color: var(--void-error-hover-text); +.error-action:hover { + border-color: var(--foreground); + background: var(--foreground); + color: var(--background); } -.void-error-action:focus-visible { - outline: 2px solid var(--void-error-muted); +.error-action:focus-visible { + outline: 2px solid var(--foreground); outline-offset: 4px; } -.void-error-action[hidden] { - display: inline-flex; +.error-action[hidden] { visibility: hidden; } -.void-error-action svg { - flex-shrink: 0; +.error-arrow { + font-size: 15px; + line-height: 14px; +} + +.error-texture { + position: absolute; + z-index: -1; + inset: 0; + display: block; + inline-size: 100%; + block-size: 100%; + border-color: var(--line); + color: var(--ink); +} + +.grid-cell, +.grid-tick { + display: none; +} + +.grid-tick { + position: absolute; + z-index: 1; + inline-size: 0; + block-size: 0; + border: 5px solid transparent; + pointer-events: none; +} + +:root[data-design='grid'] .error-page { + display: grid; + grid-template-columns: minmax(24px, 1fr) minmax(0, 456px) minmax(24px, 1fr); + grid-template-rows: minmax(24px, 1fr) minmax(272px, auto) minmax(24px, 1fr); + align-items: stretch; +} + +:root[data-design='grid'] .grid-cell, +:root[data-design='grid'] .error-content { + min-inline-size: 0; + border-inline-end: 1px solid var(--line); + border-block-end: 1px solid var(--line); +} + +:root[data-design='grid'] .grid-cell { + display: block; +} + +:root[data-design='grid'] .grid-cell:nth-child(3n) { + border-inline-end: 0; +} + +:root[data-design='grid'] .grid-cell:nth-child(n + 7) { + border-block-end: 0; +} + +:root[data-design='grid'] .error-content { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; +} + +:root[data-design='grid'] .grid-tick { + display: block; +} + +.grid-tick.left { + left: -1px; + border-left-color: var(--corner); +} + +.grid-tick.right { + right: 0; + border-right-color: var(--corner); +} + +.grid-tick.top { + top: -5px; +} + +.grid-tick.bottom { + bottom: -5px; +} + +:root[data-design='grid'] .error-texture, +:root[data-design='fold'] .error-texture { + display: none; +} + +:root[data-design='fold'] .error-page { + padding: 40px 42px; +} + +:root[data-design='fold'] .error-content { + flex: 0 1 560px; + margin-inline-end: auto; + padding: 0; + text-align: start; +} + +:root[data-design='fold'] h1 { + flex-direction: row; + align-items: baseline; + gap: 14px; +} + +:root[data-design='fold'] .error-code, +:root[data-design='fold'] .error-title { + font-size: 26px; + line-height: 1.2; + letter-spacing: -0.025em; +} + +:root[data-design='fold'] .error-code { + flex: none; + color: var(--muted); + font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace; + font-weight: 400; + letter-spacing: -0.045em; +} + +:root[data-design='fold'] .error-description { + margin-block-start: 12px; + font-size: 14px; + line-height: 1.6; + text-wrap: pretty; +} + +:root[data-design='fold'] .error-action { + margin-block-start: 24px; } @supports (corner-shape: squircle) { - .void-error-action { + .error-action { border-radius: 12px; corner-shape: squircle; } } -@media (max-width: 398px) { - .void-error-content h1 { - font-size: 30px; +@media (pointer: coarse) { + .error-action { + min-block-size: 40px; + } +} + +@media (max-width: 620px) { + :root[data-design='fold'] .error-page { + padding: 36px 30px; + } + + :root[data-design='fold'] .error-code, + :root[data-design='fold'] .error-title { + font-size: 24px; + } +} + +@media (max-width: 440px) { + .error-code { + font-size: 56px; + } + + :root[data-design='grid'] .error-page { + grid-template-columns: 24px minmax(0, 1fr) 24px; + } + + :root[data-design='grid'] .error-content { + padding: 24px; + } + + :root[data-design='fold'] .error-action { + margin-block-start: 20px; } } diff --git a/packages/void/src/runtime/routing-errors.ts b/packages/void/src/runtime/routing-errors.ts index c3febda40..b7e66e967 100644 --- a/packages/void/src/runtime/routing-errors.ts +++ b/packages/void/src/runtime/routing-errors.ts @@ -1,10 +1,7 @@ import type { Context } from 'hono'; -import notFoundPage from './error-pages/404.html?raw'; -import serverErrorPage from './error-pages/500.html?raw'; -import errorPageCss from './error-pages/style.css?raw'; +import { renderDefaultErrorPage } from './error-pages/index.ts'; type ErrorPageStatus = 404 | 500; -const errorPages = { 404: notFoundPage, 500: serverErrorPage }; type StackFrame = { functionName: string; @@ -579,11 +576,7 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere } function renderErrorPage(status: ErrorPageStatus, title: string, error?: unknown): string { - // Inline the shared stylesheet so fallback pages do not depend on asset serving. - const page = errorPages[status].replace( - '', - ``, - ); + const page = renderDefaultErrorPage(status); const html = injectErrorAssetCss(page, getErrorAssetCss(error)); return dev && status === 500 && error ? injectDevErrorOverlay(html, renderDevErrorOverlay(status, title, error)) diff --git a/packages/void/test/integration/error-pages.test.ts b/packages/void/test/integration/error-pages.test.ts index b7e502385..80da3bded 100644 --- a/packages/void/test/integration/error-pages.test.ts +++ b/packages/void/test/integration/error-pages.test.ts @@ -18,6 +18,44 @@ afterAll(async () => { }); describe('default error page actions', () => { + it.each([ + ['signal', 0], + ['grid', 0.4], + ['fold', 0.8], + ] as const)( + 'renders the %s layout for both statuses without fetching assets', + async (design, random) => { + const page = await browser.newPage(); + const failures: Array = []; + const requests: Array = []; + page.on('pageerror', (error) => failures.push(error.message)); + page.on('request', (request) => requests.push(request.url())); + await page.addInitScript((value) => { + Math.random = () => value; + }, random); + await page.route('https://app.test/**', async (route) => { + const status = new URL(route.request().url()).pathname === '/404' ? 404 : 500; + const response = errors.createResponse(new Request(route.request().url()), status, 'Error'); + return route.fulfill({ status, contentType: 'text/html', body: await response.text() }); + }); + try { + for (const status of [404, 500]) { + await page.goto(`https://app.test/${status}`); + expect(await page.locator('html').getAttribute('data-design')).toBe(design); + expect(await page.getByRole('heading', { level: 1 }).count()).toBe(1); + expect(await page.getByRole('heading', { level: 1 }).innerText()).toContain( + String(status), + ); + expect(await page.evaluate(() => document.fonts.size)).toBe(0); + } + expect(requests).toEqual(['https://app.test/404', 'https://app.test/500']); + expect(failures).toEqual([]); + } finally { + await page.close(); + } + }, + ); + it('shows Go back only when a previous entry exists and returns to that entry', async () => { const page = await browser.newPage(); page.setDefaultTimeout(10_000); diff --git a/packages/void/test/integration/static-pages.test.ts b/packages/void/test/integration/static-pages.test.ts index 27f42cafc..8e264ed9f 100644 --- a/packages/void/test/integration/static-pages.test.ts +++ b/packages/void/test/integration/static-pages.test.ts @@ -30,6 +30,12 @@ describe('playground/static-pages build (output: static)', () => { expect(existsSync(clientManifest)).toBe(true); }); + it('emits a self-contained 404 page for static hosting', () => { + const html = readFileSync(join(DIST, 'client/404.html'), 'utf8'); + expect(html).toContain('404'); + expect(html).not.toMatch(/]+rel="stylesheet"|]+src=/); + }); + it('prerenders index.html with production asset URLs', () => { const indexPath = join(DIST, 'client/index.html'); expect(existsSync(indexPath)).toBe(true); diff --git a/packages/void/test/unit/prerender.test.ts b/packages/void/test/unit/prerender.test.ts index a1893cc57..97d8a7214 100644 --- a/packages/void/test/unit/prerender.test.ts +++ b/packages/void/test/unit/prerender.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; import { toSlash } from 'pathslash'; @@ -9,6 +9,7 @@ import { collectPrerenderPaths, collectPrerenderPathsNode, collectPrerenderWorkerModules, + writeDefaultNotFoundPage, } from '../../src/prerender.ts'; import { INTERNAL_ENV_KEYS, stripInternalEnvKeys } from '../../src/shared/env.ts'; @@ -20,6 +21,31 @@ function createTempWorkerDir(): string { return dir; } +describe('static 404 page', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('emits a self-contained default page', () => { + const clientDir = join(createTempWorkerDir(), 'client'); + writeDefaultNotFoundPage(clientDir); + const html = readFileSync(join(clientDir, '404.html'), 'utf8'); + expect(html).toContain(''); + expect(html).toContain('404'); + expect(html).not.toMatch(/]+rel="stylesheet"|]+src=/); + }); + + it('preserves a custom page', () => { + const clientDir = createTempWorkerDir(); + const html = '

Custom not found page

'; + writeFileSync(join(clientDir, '404.html'), html); + writeDefaultNotFoundPage(clientDir); + expect(readFileSync(join(clientDir, '404.html'), 'utf8')).toBe(html); + }); +}); + describe('stripInternalEnvKeys', () => { it.each([undefined, {}])('returns empty sanitized + empty stripped for %j', (input) => { const result = stripInternalEnvKeys(input); diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index c30a045e9..71c0c6b37 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -149,7 +149,7 @@ export default defineConfig({ { name: 'void:raw-assets', load: { - filter: { id: /\.(?:css|html)\?raw$/ }, + filter: { id: /\.(?:css|html|js)\?raw$/ }, handler(id) { const file = id.slice(0, -'?raw'.length); this.addWatchFile(file); From a249afbceb8f5995b31255892627be9a5ef0d444 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Tue, 27 Oct 2026 18:22:19 +0800 Subject: [PATCH 3/5] fix(pages): preserve existing static build output --- docs/guide/ssg.md | 2 -- packages/void/src/prerender.ts | 17 +++-------- .../test/integration/static-pages.test.ts | 6 ---- packages/void/test/unit/prerender.test.ts | 28 +------------------ 4 files changed, 5 insertions(+), 48 deletions(-) diff --git a/docs/guide/ssg.md b/docs/guide/ssg.md index 46e34216a..1fd466de5 100644 --- a/docs/guide/ssg.md +++ b/docs/guide/ssg.md @@ -19,8 +19,6 @@ When `output` is `"static"`: - Dynamic pages without `getPrerenderPaths()` are implicitly not prerendered (the paths aren't known at build time). - The build output is self-contained and works for direct Cloudflare deployment, self-hosting, or `void deploy`. -Static output includes a default `404.html` for missing pages. Add `public/404.html` to use your own error page instead. When using another static host, configure it to serve this file with a 404 status for missing URLs. - ## Per-page overrides Individual pages can opt out of prerendering: diff --git a/packages/void/src/prerender.ts b/packages/void/src/prerender.ts index 906c82c29..892766bda 100644 --- a/packages/void/src/prerender.ts +++ b/packages/void/src/prerender.ts @@ -15,7 +15,6 @@ import { type PrerenderResult, } from './prerender-render.ts'; export type { PrerenderResult } from './prerender-render.ts'; -import { renderDefaultErrorPage } from './runtime/error-pages/index.ts'; import { scopedAccessCredentials } from './shared/cf-access.ts'; import { collectFiles } from './shared/collect-files.ts'; import { stripInternalEnvKeys } from './shared/env.ts'; @@ -39,15 +38,6 @@ export type PrerenderRunResult = PrerenderResult & { paths: Array; }; -export function writeDefaultNotFoundPage(clientDir: string): void { - // Preserve a public/404.html or a page explicitly prerendered at /404. - const path = join(clientDir, '404.html'); - if (!existsSync(path)) { - mkdirSync(clientDir, { recursive: true }); - writeFileSync(path, renderDefaultErrorPage(404)); - } -} - function normalizeWorkerModulePath(path: string): string { return path.replace(/\\/g, '/').replace(/^\.\//, ''); } @@ -349,6 +339,10 @@ export async function prerenderPages(options: { const runner = await createCloudflarePrerenderRunner(options); try { const paths = await runner.collectPaths(); + if (paths.length === 0) { + return { paths, rendered: [], failed: [] }; + } + const result = await renderPrerenderPaths( paths, options.clientDir, @@ -362,7 +356,6 @@ export async function prerenderPages(options: { } }, ); - writeDefaultNotFoundPage(options.clientDir); return { paths, ...result }; } finally { await runner.close(); @@ -488,8 +481,6 @@ export async function prerenderPagesNode(options: { clientDir: options.clientDir, }); - writeDefaultNotFoundPage(options.clientDir); - for (const path of result.rendered) { voidLog(`prerender ${pc.blue(outputFileForPath(path))}`); } diff --git a/packages/void/test/integration/static-pages.test.ts b/packages/void/test/integration/static-pages.test.ts index 8e264ed9f..27f42cafc 100644 --- a/packages/void/test/integration/static-pages.test.ts +++ b/packages/void/test/integration/static-pages.test.ts @@ -30,12 +30,6 @@ describe('playground/static-pages build (output: static)', () => { expect(existsSync(clientManifest)).toBe(true); }); - it('emits a self-contained 404 page for static hosting', () => { - const html = readFileSync(join(DIST, 'client/404.html'), 'utf8'); - expect(html).toContain('404'); - expect(html).not.toMatch(/]+rel="stylesheet"|]+src=/); - }); - it('prerenders index.html with production asset URLs', () => { const indexPath = join(DIST, 'client/index.html'); expect(existsSync(indexPath)).toBe(true); diff --git a/packages/void/test/unit/prerender.test.ts b/packages/void/test/unit/prerender.test.ts index 97d8a7214..a1893cc57 100644 --- a/packages/void/test/unit/prerender.test.ts +++ b/packages/void/test/unit/prerender.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; import { toSlash } from 'pathslash'; @@ -9,7 +9,6 @@ import { collectPrerenderPaths, collectPrerenderPathsNode, collectPrerenderWorkerModules, - writeDefaultNotFoundPage, } from '../../src/prerender.ts'; import { INTERNAL_ENV_KEYS, stripInternalEnvKeys } from '../../src/shared/env.ts'; @@ -21,31 +20,6 @@ function createTempWorkerDir(): string { return dir; } -describe('static 404 page', () => { - afterEach(() => { - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('emits a self-contained default page', () => { - const clientDir = join(createTempWorkerDir(), 'client'); - writeDefaultNotFoundPage(clientDir); - const html = readFileSync(join(clientDir, '404.html'), 'utf8'); - expect(html).toContain(''); - expect(html).toContain('404'); - expect(html).not.toMatch(/]+rel="stylesheet"|]+src=/); - }); - - it('preserves a custom page', () => { - const clientDir = createTempWorkerDir(); - const html = '

Custom not found page

'; - writeFileSync(join(clientDir, '404.html'), html); - writeDefaultNotFoundPage(clientDir); - expect(readFileSync(join(clientDir, '404.html'), 'utf8')).toBe(html); - }); -}); - describe('stripInternalEnvKeys', () => { it.each([undefined, {}])('returns empty sanitized + empty stripped for %j', (input) => { const result = stripInternalEnvKeys(input); From 7364e19b47fe57c6d10b52e1930110594a0aab88 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Tue, 27 Oct 2026 19:53:35 +0800 Subject: [PATCH 4/5] fix(pages): validate error asset blocks and improve mobile layout --- .../void/src/runtime/error-pages/404.html | 14 ++++++ .../void/src/runtime/error-pages/500.html | 14 ++++++ .../void/src/runtime/error-pages/index.ts | 10 ++-- .../void/src/runtime/error-pages/inline.ts | 22 +++++++++ .../void/src/runtime/error-pages/style.css | 4 ++ .../void/test/unit/error-page-assets.test.ts | 49 +++++++++++++++++++ 6 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 packages/void/src/runtime/error-pages/inline.ts create mode 100644 packages/void/test/unit/error-page-assets.test.ts diff --git a/packages/void/src/runtime/error-pages/404.html b/packages/void/src/runtime/error-pages/404.html index cc5f12025..835a02779 100644 --- a/packages/void/src/runtime/error-pages/404.html +++ b/packages/void/src/runtime/error-pages/404.html @@ -4,8 +4,22 @@ 404: Not Found + + + + + +
diff --git a/packages/void/src/runtime/error-pages/500.html b/packages/void/src/runtime/error-pages/500.html index 1aa5791ac..690e29849 100644 --- a/packages/void/src/runtime/error-pages/500.html +++ b/packages/void/src/runtime/error-pages/500.html @@ -4,8 +4,22 @@ 500: Internal Server Error + + + + + +
diff --git a/packages/void/src/runtime/error-pages/index.ts b/packages/void/src/runtime/error-pages/index.ts index 06f6c361c..0765607cd 100644 --- a/packages/void/src/runtime/error-pages/index.ts +++ b/packages/void/src/runtime/error-pages/index.ts @@ -1,12 +1,14 @@ import notFoundPage from './404.html?raw'; import serverErrorPage from './500.html?raw'; +import { inlineErrorPageAssets } from './inline.ts'; import script from './script.js?raw'; import stylesheet from './style.css?raw'; -const pages = { 404: notFoundPage, 500: serverErrorPage }; +const pages = { + 404: inlineErrorPageAssets(notFoundPage, stylesheet, script), + 500: inlineErrorPageAssets(serverErrorPage, stylesheet, script), +}; export function renderDefaultErrorPage(status: 404 | 500): string { - return pages[status] - .replace('', () => ``) - .replace('', () => ``); + return pages[status]; } diff --git a/packages/void/src/runtime/error-pages/inline.ts b/packages/void/src/runtime/error-pages/inline.ts new file mode 100644 index 000000000..76d2a168f --- /dev/null +++ b/packages/void/src/runtime/error-pages/inline.ts @@ -0,0 +1,22 @@ +const closing = ''; + +function replaceAssetBlock(html: string, name: 'css' | 'js', content: string): string { + const opening = ``; + const start = html.indexOf(opening); + const end = html.indexOf(closing, start + opening.length); + if (start === -1 || end === -1 || html.lastIndexOf(opening) !== start) { + throw new Error(`Expected one complete ${name} block in the default error page.`); + } + return html.slice(0, start) + content + html.slice(end + closing.length); +} + +export function inlineErrorPageAssets(html: string, stylesheet: string, script: string): string { + if (html.split(closing).length !== 3) { + throw new Error('Expected two endinject markers in the default error page.'); + } + return replaceAssetBlock( + replaceAssetBlock(html, 'css', ``), + 'js', + ``, + ); +} diff --git a/packages/void/src/runtime/error-pages/style.css b/packages/void/src/runtime/error-pages/style.css index 01610e64b..2602ba791 100644 --- a/packages/void/src/runtime/error-pages/style.css +++ b/packages/void/src/runtime/error-pages/style.css @@ -283,6 +283,10 @@ body { padding: 24px; } + :root[data-design='fold'] .error-code { + display: none; + } + :root[data-design='fold'] .error-action { margin-block-start: 20px; } diff --git a/packages/void/test/unit/error-page-assets.test.ts b/packages/void/test/unit/error-page-assets.test.ts new file mode 100644 index 000000000..3a9ccc166 --- /dev/null +++ b/packages/void/test/unit/error-page-assets.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { inlineErrorPageAssets } from '../../src/runtime/error-pages/inline.ts'; + +const template = ` + + + + + + + + + +

404

`; + +describe('error page assets', () => { + it('inlines formatted asset blocks, removes preview comments and preserves page markup', () => { + const css = 'body::before { content: "$&"; }'; + const js = 'console.log("$` $& $1");'; + const html = inlineErrorPageAssets(template, css, js); + expect(html).toContain(``); + expect(html).toContain(``); + expect(html).toContain('

404

'); + expect(html).not.toContain('including this comment'); + expect(html).not.toMatch(/]+src\s*=/); + }); + + it('rejects missing and duplicate asset markers', () => { + for (const name of ['css', 'js']) { + const marker = ``; + expect(() => inlineErrorPageAssets(template.replace(marker, ''), '', '')).toThrow( + `${name} block`, + ); + expect(() => inlineErrorPageAssets(template + marker, '', '')).toThrow(`${name} block`); + } + }); + + it('rejects missing or extra closing markers', () => { + const marker = ''; + expect(() => inlineErrorPageAssets(template.replace(marker, ''), '', '')).toThrow('endinject'); + expect(() => inlineErrorPageAssets(template + marker, '', '')).toThrow('endinject'); + }); +}); From 663c4fa6db89e2efc54b1c7b032c7fec38f4f6d3 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Tue, 27 Oct 2026 22:53:51 +0800 Subject: [PATCH 5/5] refactor(pages): simplify error asset inlining --- packages/void/src/runtime/error-pages/inline.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/void/src/runtime/error-pages/inline.ts b/packages/void/src/runtime/error-pages/inline.ts index 76d2a168f..92df8fb27 100644 --- a/packages/void/src/runtime/error-pages/inline.ts +++ b/packages/void/src/runtime/error-pages/inline.ts @@ -14,9 +14,11 @@ export function inlineErrorPageAssets(html: string, stylesheet: string, script: if (html.split(closing).length !== 3) { throw new Error('Expected two endinject markers in the default error page.'); } - return replaceAssetBlock( - replaceAssetBlock(html, 'css', ``), - 'js', - ``, - ); + for (const [name, content] of [ + ['css', ``], + ['js', ``], + ] as const) { + html = replaceAssetBlock(html, name, content); + } + return html; }