From 65f2ffe57d26e5a08ef4b561733bf08fb8549469 Mon Sep 17 00:00:00 2001 From: eleith Date: Sun, 1 Nov 2026 10:06:46 -0800 Subject: [PATCH 1/2] [web] add Markdown-customizable error pages --- .changeset/custom-error-markdown.md | 9 + apps/web/src/app.d.ts | 5 +- apps/web/src/hooks.server.test.ts | 10 + apps/web/src/hooks.server.ts | 2 + .../components/layout/simple/SeoHead.svelte | 36 +- .../lib/content/components/404.default.svelte | 28 +- .../lib/content/components/ErrorPage.svelte | 22 ++ .../lib/content/components/ErrorPage.test.ts | 126 +++++++ .../content/components/error.default.svelte | 29 +- apps/web/src/lib/content/errors.ts | 5 + apps/web/src/lib/server/error-pages.test.ts | 227 ++++++++++++ apps/web/src/lib/server/error-pages.ts | 52 +++ apps/web/src/lib/server/pages.test.ts | 6 + apps/web/src/lib/server/pages.ts | 9 +- apps/web/src/routes/+error.svelte | 9 +- .../fixtures/templates/src/hooks.server.js | 9 +- .../templates/src/routes/+error.svelte | 7 + .../src/routes/errors/[kind]/+page.server.js | 14 + .../src/routes/errors/endpoint/+server.js | 5 + apps/web/test/templates/routes.test.ts | 75 ++++ apps/web/test/templates/ssr.test.ts | 339 +++++++++++++++++- docs/web/config.example.yaml | 12 + docs/web/config.md | 15 + .../web/config/src/lib/config/pages.test.ts | 91 ++++- packages/web/config/src/lib/config/schema.ts | 30 ++ .../src/lib/config/web.config.schema.json | 59 +++ packages/web/templates/config.schema.json | 59 +++ 27 files changed, 1237 insertions(+), 53 deletions(-) create mode 100644 .changeset/custom-error-markdown.md create mode 100644 apps/web/src/lib/content/components/ErrorPage.svelte create mode 100644 apps/web/src/lib/content/components/ErrorPage.test.ts create mode 100644 apps/web/src/lib/content/errors.ts create mode 100644 apps/web/src/lib/server/error-pages.test.ts create mode 100644 apps/web/src/lib/server/error-pages.ts create mode 100644 apps/web/test/fixtures/templates/src/routes/+error.svelte create mode 100644 apps/web/test/fixtures/templates/src/routes/errors/[kind]/+page.server.js create mode 100644 apps/web/test/fixtures/templates/src/routes/errors/endpoint/+server.js diff --git a/.changeset/custom-error-markdown.md b/.changeset/custom-error-markdown.md new file mode 100644 index 00000000..6d0d32a6 --- /dev/null +++ b/.changeset/custom-error-markdown.md @@ -0,0 +1,9 @@ +--- +"@luzzle/web": patch +"@luzzle/web.config": patch +--- + +Add independently configurable Markdown bodies and recovery links for 404, +remaining 4xx, and server-error pages through native SvelteKit error handling. +Keep statuses, API responses and healthy pages unchanged, with safe built-in +fallbacks when selected Markdown is unavailable. diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index da577a33..6efabe92 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,11 +1,14 @@ import { type AppConfigPublic } from '#lib/server/config.js' import type { LuzzleSession as Session } from '#lib/server/auth/index.js' +import type { ErrorPresentation } from '#lib/content/errors.js' // See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { namespace App { - // interface Error {} + interface Error { + presentation?: ErrorPresentation + } interface Locals { inlineConfiguredStyles(): void auth(): Promise diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index e317ee81..2b343f9a 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -15,6 +15,9 @@ const fixture = vi.hoisted(() => ({ createAuth: vi.fn(() => ({ id: 'native-auth' })), createAuthHandle: vi.fn(() => (({ event, resolve }) => resolve(event)) satisfies Handle) })) +const createErrorHandler = vi.hoisted(() => vi.fn(() => vi.fn())) +vi.mock('#lib/server/error-pages.js', () => ({ createErrorHandler })) + vi.mock('$app/env', () => ({ get building() { return fixture.building @@ -98,6 +101,13 @@ test('awaits configured CSS once when the server hooks load, not per request', a expect(fixture.prepareConfiguredStyles).toHaveBeenCalledTimes(1) }) +test('exports the native error hook without preparing error content during startup', async () => { + const { handleError } = await import('./hooks.server.js') + expect(createErrorHandler).toHaveBeenCalledExactlyOnceWith(fixture.config) + expect(handleError).toBe(createErrorHandler.mock.results[0].value) + expect(handleError).not.toHaveBeenCalled() +}) + test('does not prepare configured CSS during framework building', async () => { fixture.building = true await import('./hooks.server.js') diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index 427821f5..50919134 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -4,6 +4,7 @@ import { config } from '#lib/server/config.js' import { createAuth, createAuthHandle } from '#lib/server/auth/index.js' import { resolveAuthUrl } from '../scripts/runtime-environment.js' import { createAssetCspHandle } from '#lib/server/asset-csp.js' +import { createErrorHandler } from '#lib/server/error-pages.js' import { prepareConfiguredStyles, renderConfiguredStyles } from '#lib/server/configured-styles.js' import '#lib/server/database/index.js' import { sequence, type Handle } from '@sveltejs/kit/hooks' @@ -75,3 +76,4 @@ function createRequestPipeline(): Handle { } export const handle = createRequestPipeline() +export const handleError = createErrorHandler(config) diff --git a/apps/web/src/lib/components/layout/simple/SeoHead.svelte b/apps/web/src/lib/components/layout/simple/SeoHead.svelte index 7cd53a27..2783284e 100644 --- a/apps/web/src/lib/components/layout/simple/SeoHead.svelte +++ b/apps/web/src/lib/components/layout/simple/SeoHead.svelte @@ -1,36 +1,40 @@ - {#if page.data.meta.title} - {page.data.meta.title} - + {#if meta.title} + {meta.title} + {:else} - {page.data.config.content.text.title} - + {content?.title ?? 'Luzzle Explorer'} + {/if} - {#if page.data.meta.description} - - + {#if meta.description} + + {:else} - - + + {/if} - {#if page.data.meta.image} - + {#if meta.image} + {:else} - + {/if} - {#if page.data.meta.type} - + {#if meta.type} + {:else} {/if} - + {#if !browser} @@ -10,10 +16,22 @@
-

404

-

page not found

-

{message || 'there are no pieces at this path.'}

- go home + {#if html !== undefined} + + {:else} +

404

+

page not found

+

+ {#if message} + {message} + {:else} + there are no pieces at this path. + {/if} +

+ {/if} + {#if home} + go home + {/if}
diff --git a/apps/web/src/lib/content/components/ErrorPage.svelte b/apps/web/src/lib/content/components/ErrorPage.svelte new file mode 100644 index 00000000..ed6a25cd --- /dev/null +++ b/apps/web/src/lib/content/components/ErrorPage.svelte @@ -0,0 +1,22 @@ + + +{#if error.status === 404} + +{:else} + +{/if} diff --git a/apps/web/src/lib/content/components/ErrorPage.test.ts b/apps/web/src/lib/content/components/ErrorPage.test.ts new file mode 100644 index 00000000..07659b2a --- /dev/null +++ b/apps/web/src/lib/content/components/ErrorPage.test.ts @@ -0,0 +1,126 @@ +import { render } from 'svelte/server' +import { expect, test, vi } from 'vitest' +import { renderMarkdown } from '#lib/content/markdown/render.server.js' +import ErrorPage from './ErrorPage.svelte' +import Page404Default from './404.default.svelte' +import PageErrorDefault from './error.default.svelte' + +const layout = vi.hoisted(() => ({ absent: false })) + +vi.mock('$app/paths', () => ({ resolve: (path: string) => `/garden/${path}` })) +vi.mock('$app/env', () => ({ browser: false })) +vi.mock('$app/state', () => ({ + page: { + url: new URL('https://garden.test/missing'), + get data() { + if (layout.absent) return {} + return { + meta: {}, + config: { + content: { text: { title: 'Garden', description: 'A garden' } }, + url: { app_assets: 'https://assets.test' } + } + } + } + }, + navigating: { to: null } +})) + +// Real default pages, Markdown and banner; only Kit's public runtime is mocked. +// These are SSR assertions, not browser navigation or hydration coverage. +function page(error: App.Error) { + return render(ErrorPage, { props: { error } }).body.replace(//g, '') +} + +function main(output: string) { + const body = output.match(/]*>([\s\S]*?)<\/main>/)?.[1] + expect(body).toBeDefined() + return body! +} + +const message = 'Safe native message & context' +const statuses = [404, 403, 500, 503] +const authored = renderMarkdown('# Take a breath\n\nBrowse [the garden](/pieces).') + +test.each(statuses)( + '%i without presentation keeps the stock page and escaped message', + (status) => { + const output = page({ status, message }) + const body = main(output) + expect(body).toMatch(new RegExp(`]*>${status === 404 ? '404' : `error ${status}`}`)) + expect(body.includes('page not found')).toBe(status === 404) + expect(body).toContain('Safe native message <detail> & context') + expect(body).not.toContain('') + expect(body).not.toContain('class="markdown"') + expect(body).toMatch(/go home<\/a>/) + expect(output).toContain('aria-label="main page"') + } +) + +test.each(statuses)('%i renders authored Markdown instead of the stock body', (status) => { + const output = page({ status, message, presentation: { html: authored, home: true } }) + const body = main(output) + expect(body).toContain('class="markdown"') + expect(body).toContain(authored) + expect(body).not.toContain('Safe native message') + expect(body).not.toContain('page not found') + expect(body).not.toMatch(/]*>(?:404|error \d+)/) + expect(body).toContain('go home') + expect(output).toContain('aria-label="main page"') +}) + +test.each([404, 403, 503])('%i home=false controls only the recovery link', (status) => { + for (const html of [undefined, '', authored]) { + const output = page({ status, message, presentation: { html, home: false } }) + const body = main(output) + expect(body).not.toContain('go home') + expect(body).not.toContain('home-button') + expect(output).toMatch(//) + if (html === undefined) { + expect(body).toContain('Safe native message') + } else { + expect(body).toContain('class="markdown"') + expect(body).not.toContain('Safe native message') + expect(body).not.toMatch(/]*>(?:404|error \d+|page not found)/) + if (html === '') expect(body).not.toMatch(/ { + const body = main(page({ status: 404, message, presentation: { html: '', home: true } })) + expect(body).toContain('class="markdown"') + expect(body).not.toContain('Safe native message') + expect(body).not.toContain('page not found') + expect(body).toContain('go home') +}) + +test('absent layout data supports authored errors and stock no-presentation rendering in SSR', () => { + layout.absent = true + try { + const customized = page({ status: 500, message, presentation: { html: authored, home: false } }) + expect(main(customized)).toContain(authored) + expect(main(customized)).not.toContain('Safe native message') + expect(main(customized)).not.toContain('go home') + expect(customized).toContain('aria-label="main page"') + + // No presentation also models the stock rendering input used by browser fallbacks, + // without asserting browser execution here. + const stock = page({ status: 404, message }) + expect(main(stock)).toContain('page not found') + expect(main(stock)).toContain('Safe native message <detail> & context') + expect(main(stock)).not.toContain('class="markdown"') + expect(main(stock)).toContain('go home') + expect(stock).toContain('aria-label="main page"') + } finally { + layout.absent = false + } +}) + +test('default components retain their own fallback copy and recovery links', () => { + const missing = main(render(Page404Default, { props: {} }).body) + const failed = main(render(PageErrorDefault, { props: { status: 500 } }).body) + expect(missing).toContain('there are no pieces at this path.') + expect(failed).toContain('an unexpected error occurred') + for (const body of [missing, failed]) expect(body).toContain('go home') +}) diff --git a/apps/web/src/lib/content/components/error.default.svelte b/apps/web/src/lib/content/components/error.default.svelte index 39a4a5b0..27120d40 100644 --- a/apps/web/src/lib/content/components/error.default.svelte +++ b/apps/web/src/lib/content/components/error.default.svelte @@ -1,9 +1,16 @@ @@ -11,9 +18,21 @@
-

error {status}

-

{message}

-
go home + {#if html !== undefined} + + {:else} +

error {status}

+

+ {#if message !== undefined} + {message} + {:else} + an unexpected error occurred + {/if} +

+ {/if} + {#if home} + go home + {/if}
diff --git a/apps/web/src/lib/content/errors.ts b/apps/web/src/lib/content/errors.ts new file mode 100644 index 00000000..a9f4c656 --- /dev/null +++ b/apps/web/src/lib/content/errors.ts @@ -0,0 +1,5 @@ +export type ErrorPresentation = { + /** Trusted server-rendered Markdown only; never arbitrary author HTML. */ + html?: string + home: boolean +} diff --git a/apps/web/src/lib/server/error-pages.test.ts b/apps/web/src/lib/server/error-pages.test.ts new file mode 100644 index 00000000..5bd2f5a3 --- /dev/null +++ b/apps/web/src/lib/server/error-pages.test.ts @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import type { RequestEvent } from '@sveltejs/kit' +import type { HandleServerError } from '@sveltejs/kit/hooks' +import { makeConfig } from './config.fixture.js' +import { createErrorHandler } from './error-pages.js' + +const fixture = vi.hoisted(() => ({ building: false, loadPageMarkdown: vi.fn() })) +vi.mock('$app/env', () => ({ + get building() { + return fixture.building + } +})) +vi.mock('$app/manifest', () => ({ + routes: [ + { id: '/(root)', page: true, endpoint: false }, + { id: '/search', page: true, endpoint: true }, + { id: '/api/health', page: false, endpoint: true }, + { id: '/(auth)/api/admin/publish', page: false, endpoint: true } + ] +})) +vi.mock('#lib/server/pages.js', () => ({ loadPageMarkdown: fixture.loadPageMarkdown })) + +function configuration() { + const config = makeConfig() + config.paths.config = '/private/site/config.yaml' + config.pages.error = { + '404': { body: 'errors/not-found.md', home: false }, + '4xx': { body: 'errors/client.md', home: true }, + '500': { body: 'errors/server.md', home: true } + } + return config +} + +type RequestOptions = { + routeId?: string | null + accept?: string | null + method?: string + isDataRequest?: boolean + isSubRequest?: boolean + isRemoteRequest?: boolean + action?: boolean +} + +function request({ + routeId = '/(root)', + accept = 'text/html', + method = 'GET', + isDataRequest = false, + isSubRequest = false, + isRemoteRequest = false, + action = false +}: RequestOptions = {}): RequestEvent { + const headers = new Headers() + if (accept !== null) headers.set('accept', accept) + if (action) headers.set('x-sveltekit-action', 'true') + return { + url: new URL('https://garden.test/missing'), + route: { id: routeId }, + request: new Request('https://garden.test/missing', { headers, method }), + isDataRequest, + isSubRequest, + isRemoteRequest + } as RequestEvent +} + +function failure(status: number, event = request(), kind: 'app' | 'framework' = 'app') { + return { kind, error: { status, message: 'Safe native message' }, event } +} + +beforeEach(() => { + vi.clearAllMocks() + fixture.building = false + fixture.loadPageMarkdown.mockResolvedValue('

Authored error

') + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) +afterEach(() => vi.restoreAllMocks()) + +test('creating the native hook does not read any configured Markdown', () => { + createErrorHandler(configuration()) + expect(fixture.loadPageMarkdown).not.toHaveBeenCalled() +}) + +test.each([ + [404, 'errors/not-found.md', false], + [400, 'errors/client.md', true], + [403, 'errors/client.md', true], + [410, 'errors/client.md', true], + [499, 'errors/client.md', true], + [500, 'errors/server.md', true], + [503, 'errors/server.md', true], + [599, 'errors/server.md', true] +] as const)( + 'status %s selects only %s without overriding native status/message', + async (status, body, home) => { + const config = configuration() + const handleError = createErrorHandler(config) + const input = failure(status) + const result = await handleError(input) + expect(result).toEqual({ presentation: { html: '

Authored error

', home } }) + expect(fixture.loadPageMarkdown).toHaveBeenCalledExactlyOnceWith(body, config.paths.config) + expect(input.error).toEqual({ status, message: 'Safe native message' }) + expect(console.error).not.toHaveBeenCalled() + } +) + +test('unmatched framework 404 uses the distinct not-found body', async () => { + const result = await createErrorHandler(configuration())( + failure(404, request({ routeId: null }), 'framework') + ) + expect(result).toMatchObject({ presentation: { home: false } }) + expect(fixture.loadPageMarkdown).toHaveBeenCalledExactlyOnceWith( + 'errors/not-found.md', + '/private/site/config.yaml' + ) +}) + +test.each([ + { routeId: '/api/health', accept: 'application/json' }, + { routeId: '/api/health', accept: 'text/html' }, + { routeId: '/(auth)/api/admin/publish', accept: 'text/html' }, + { routeId: '/(auth)/api/admin/publish', isDataRequest: true }, + { accept: 'application/json' }, + { accept: 'application/json, text/html' }, + { accept: 'text/html;q=0.2, application/json;q=0.9' }, + { routeId: null, accept: 'application/json' }, + { isSubRequest: true }, + { isRemoteRequest: true }, + { action: true }, + { method: 'POST', accept: null }, + { method: 'POST', accept: '*/*' }, + { method: 'POST', accept: 'application/json' } +])('leaves non-page/error JSON or internal requests untouched: %j', async (options) => { + expect(await createErrorHandler(configuration())(failure(404, request(options)))).toBeUndefined() + expect(fixture.loadPageMarkdown).not.toHaveBeenCalled() +}) + +test.each([ + { isDataRequest: true, accept: 'application/json' }, + { accept: 'application/json;q=0.2, text/html;q=0.9' }, + { routeId: '/search', accept: 'text/html' }, + { accept: null }, + { method: 'POST', accept: 'text/html' } +])('server-backed page requests receive selected content: %j', async (options) => { + expect(await createErrorHandler(configuration())(failure(403, request(options)))).toMatchObject({ + presentation: { html: '

Authored error

' } + }) + expect(fixture.loadPageMarkdown).toHaveBeenCalledExactlyOnceWith( + 'errors/client.md', + '/private/site/config.yaml' + ) +}) + +test('unknown errors keep private diagnostics server-side and native safe defaults intact', async () => { + const error = new Error('private exception /deployment/secret.yaml') + const result = await createErrorHandler(configuration())({ + kind: 'unknown', + error, + event: request() + }) + expect(result).toEqual({ presentation: { html: '

Authored error

', home: true } }) + expect(JSON.stringify(result)).not.toMatch(/secret|private|status|message|stack/) + expect(console.error).toHaveBeenCalledExactlyOnceWith(error) + expect(fixture.loadPageMarkdown).toHaveBeenCalledExactlyOnceWith( + 'errors/server.md', + '/private/site/config.yaml' + ) +}) + +test('native remote validation is logged without loading error content', async () => { + const input: Parameters[0] = { + kind: 'validation', + error: { status: 400, message: 'Bad Request' }, + issues: [], + event: request() + } + expect(await createErrorHandler(configuration())(input)).toBeUndefined() + expect(fixture.loadPageMarkdown).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith('Remote function schema validation failed:', []) +}) + +test('building and omitted defaults do not read or attach error presentation', async () => { + fixture.building = true + expect(await createErrorHandler(configuration())(failure(404))).toBeUndefined() + fixture.building = false + expect(await createErrorHandler(makeConfig())(failure(404))).toBeUndefined() + expect(fixture.loadPageMarkdown).not.toHaveBeenCalled() +}) + +test('home control is independent of Markdown and requires no file', async () => { + const config = configuration() + config.pages.error['404'] = { home: false } + expect(await createErrorHandler(config)(failure(404))).toEqual({ presentation: { home: false } }) + expect(fixture.loadPageMarkdown).not.toHaveBeenCalled() +}) + +test.each([false, true])( + 'unreadable Markdown retains built-in copy and home=%s without leaking its path', + async (home) => { + const config = configuration() + config.pages.error['404'].home = home + fixture.loadPageMarkdown.mockRejectedValueOnce( + new Error('Cannot read /private/site/errors/not-found.md') + ) + const result = await createErrorHandler(config)(failure(404)) + expect(result).toEqual({ presentation: { home } }) + expect(JSON.stringify(result)).not.toMatch(/private|not-found|html|stack/) + expect(console.error).toHaveBeenCalledExactlyOnceWith( + 'Unable to load configured error Markdown; using the built-in page.' + ) + } +) + +test('a synchronous path-resolution failure also falls back without throwing', async () => { + fixture.loadPageMarkdown.mockImplementationOnce(() => { + throw new Error('Missing config path') + }) + await expect(createErrorHandler(configuration())(failure(404))).resolves.toEqual({ + presentation: { home: false } + }) +}) + +test('an explicitly empty Markdown body is still the selected presentation', async () => { + fixture.loadPageMarkdown.mockResolvedValueOnce('') + expect(await createErrorHandler(configuration())(failure(404))).toEqual({ + presentation: { html: '', home: false } + }) +}) diff --git a/apps/web/src/lib/server/error-pages.ts b/apps/web/src/lib/server/error-pages.ts new file mode 100644 index 00000000..9cca08d1 --- /dev/null +++ b/apps/web/src/lib/server/error-pages.ts @@ -0,0 +1,52 @@ +import { building } from '$app/env' +import { routes } from '$app/manifest' +import type { HandleServerError } from '@sveltejs/kit/hooks' +import type { RequestEvent } from '@sveltejs/kit' +import type { ErrorPresentation } from '#lib/content/errors.js' +import { negotiate } from '#lib/utils/http.js' +import type { AppConfig } from './config.js' +import { loadPageMarkdown } from './pages.js' + +const pageRoutes = new Set(routes.filter((route) => route.page).map((route) => route.id)) + +function usesErrorPage(event: RequestEvent): boolean { + if (event.isSubRequest || event.isRemoteRequest) return false + if (event.request.headers.get('x-sveltekit-action') === 'true') return false + if (event.route.id !== null && !pageRoutes.has(event.route.id)) return false + if (event.isDataRequest) return true + + const fallback = event.request.method === 'POST' ? '*/*' : 'text/html' + const accept = event.request.headers.get('accept') ?? fallback + return negotiate(accept, ['application/json', 'text/html']) === 'text/html' +} + +function selectErrorPage(status: number, pages: AppConfig['pages']['error']) { + if (status === 404) return pages['404'] + if (status >= 400 && status < 500) return pages['4xx'] + if (status >= 500 && status < 600) return pages['500'] +} + +export function createErrorHandler(configuration: AppConfig): HandleServerError { + return async (input) => { + if (input.kind === 'unknown') console.error(input.error) + if (input.kind === 'validation') { + console.error('Remote function schema validation failed:', input.issues) + return + } + if (building || !usesErrorPage(input.event)) return + + const status = input.kind === 'unknown' ? 500 : input.error.status + const page = selectErrorPage(status, configuration.pages.error) + if (!page) return + if (page.body === undefined && page.home) return + + const presentation: ErrorPresentation = { home: page.home } + if (page.body === undefined) return { presentation } + try { + presentation.html = await loadPageMarkdown(page.body, configuration.paths.config) + } catch { + console.error('Unable to load configured error Markdown; using the built-in page.') + } + return { presentation } + } +} diff --git a/apps/web/src/lib/server/pages.test.ts b/apps/web/src/lib/server/pages.test.ts index 1535e9e4..3adce2ff 100644 --- a/apps/web/src/lib/server/pages.test.ts +++ b/apps/web/src/lib/server/pages.test.ts @@ -27,6 +27,12 @@ test('relative references follow the logical supplied config path', async () => expect(loadMarkdownFile).toHaveBeenLastCalledWith('/deployment/site/home.md') }) +test('an explicit config path binds Markdown to the matching server configuration', async () => { + await loadPageMarkdown('errors/not-found.md', '/fixture/site/config.yaml') + expect(loadMarkdownFile).toHaveBeenCalledExactlyOnceWith('/fixture/site/errors/not-found.md') + expect(config.paths.config).toBe('/deployment/config/config.yaml') +}) + test('loads only the requested section rather than discovering or preloading other pages', async () => { await loadPageMarkdown('home.md') expect(loadMarkdownFile.mock.calls).toEqual([['/deployment/config/home.md']]) diff --git a/apps/web/src/lib/server/pages.ts b/apps/web/src/lib/server/pages.ts index 9f14271e..c71ae85d 100644 --- a/apps/web/src/lib/server/pages.ts +++ b/apps/web/src/lib/server/pages.ts @@ -2,9 +2,12 @@ import path from 'node:path' import { config } from '#lib/server/config.js' import { loadMarkdownFile } from '#lib/content/markdown/files.server.js' -export function loadPageMarkdown(file?: string): Promise { +export function loadPageMarkdown( + file?: string, + configFilename = config.paths.config +): Promise { if (file === undefined) return Promise.resolve(undefined) - if (!config.paths.config) throw new Error('A supplied config path is required for page Markdown') - const filename = path.resolve(path.dirname(config.paths.config), file) + if (!configFilename) throw new Error('A supplied config path is required for page Markdown') + const filename = path.resolve(path.dirname(configFilename), file) return loadMarkdownFile(filename) } diff --git a/apps/web/src/routes/+error.svelte b/apps/web/src/routes/+error.svelte index db7211ac..5ef3fd66 100644 --- a/apps/web/src/routes/+error.svelte +++ b/apps/web/src/routes/+error.svelte @@ -1,13 +1,8 @@ -{#if error.status === 404} - -{:else} - -{/if} + diff --git a/apps/web/test/fixtures/templates/src/hooks.server.js b/apps/web/test/fixtures/templates/src/hooks.server.js index 32fab96e..8a7c63d7 100644 --- a/apps/web/test/fixtures/templates/src/hooks.server.js +++ b/apps/web/test/fixtures/templates/src/hooks.server.js @@ -1,9 +1,16 @@ import { building } from '$app/env' import { loadConfig } from '@luzzle/web.templates/config' import { prepareConfiguredStyles, renderConfiguredStyles } from '#lib/server/configured-styles.js' +import { createErrorHandler } from '#lib/server/error-pages.js' -const configuredStyles = building ? '' : await prepareConfiguredStyles(loadConfig('./config.yaml')) +const configuration = building ? undefined : loadConfig('./config.yaml') +const configuredStyles = configuration ? await prepareConfiguredStyles(configuration) : '' +const handleConfiguredError = configuration ? createErrorHandler(configuration) : undefined export function handle(request) { return renderConfiguredStyles(request, configuredStyles) } + +export function handleError(event) { + return handleConfiguredError?.(event) +} diff --git a/apps/web/test/fixtures/templates/src/routes/+error.svelte b/apps/web/test/fixtures/templates/src/routes/+error.svelte new file mode 100644 index 00000000..51944d24 --- /dev/null +++ b/apps/web/test/fixtures/templates/src/routes/+error.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/test/fixtures/templates/src/routes/errors/[kind]/+page.server.js b/apps/web/test/fixtures/templates/src/routes/errors/[kind]/+page.server.js new file mode 100644 index 00000000..778b22ce --- /dev/null +++ b/apps/web/test/fixtures/templates/src/routes/errors/[kind]/+page.server.js @@ -0,0 +1,14 @@ +import { error } from '@sveltejs/kit' + +export function load({ params }) { + if (params.kind === 'unknown') throw new Error('Fixture private failure: /private/fixture-secret') + const status = Number(params.kind) + if ([400, 403, 404, 500, 503].includes(status)) error(status, `Fixture expected ${status}`) + error(404, 'Unknown fixture error kind') +} + +export const actions = { + default() { + error(403, 'Fixture action denied') + } +} diff --git a/apps/web/test/fixtures/templates/src/routes/errors/endpoint/+server.js b/apps/web/test/fixtures/templates/src/routes/errors/endpoint/+server.js new file mode 100644 index 00000000..6d329adc --- /dev/null +++ b/apps/web/test/fixtures/templates/src/routes/errors/endpoint/+server.js @@ -0,0 +1,5 @@ +import { error } from '@sveltejs/kit' + +export function GET() { + error(503, 'Fixture endpoint unavailable') +} diff --git a/apps/web/test/templates/routes.test.ts b/apps/web/test/templates/routes.test.ts index e0d255c9..8d5976b1 100644 --- a/apps/web/test/templates/routes.test.ts +++ b/apps/web/test/templates/routes.test.ts @@ -1239,6 +1239,81 @@ describe('stock pieces and configured site sections through production HTTP', () expect(await machineFeedSnapshot()).toEqual(originalMachineFeeds) }) + test('adopts runtime error Markdown for framework and expected 404s without leaking into healthy pages or APIs', async () => { + const copies = ['Runtime missing copy', 'Runtime client copy', 'Runtime server copy'] + for (const [index, key] of ['404', '4xx', '500'].entries()) + await writeFile(path.join(directory, `sections/error-${key}.md`), `# ${copies[index]}`) + await restartWithPages({ + error: { + '404': { body: 'sections/error-404.md', home: false }, + '4xx': { body: 'sections/error-4xx.md' }, + '500': { body: 'sections/error-500.md' } + } + }) + function expectNoErrorContent(body: string) { + for (const value of [...copies, 'presentation', directory, configFilename, 'sections/error-']) + expect(body).not.toContain(value) + } + for (const pathname of ['/no-such-runtime-page', '/pieces/cutover-kind/missing']) { + const response = await request(pathname) + expect(response.status, pathname).toBe(404) + const html = await response.text() + expect(html).toContain('

Runtime missing copy

') + expect(html).not.toContain('Runtime client copy') + expect(html).not.toContain('Runtime server copy') + expect(html).toContain('aria-label="main page"') + expect(linkTargets(html, pathname)).toContain(new URL('/', origin).href) + expect(html).not.toMatch(/]*\bclass="[^"]*\bhome-button\b/) + for (const privateValue of [directory, configFilename, 'sections/error-']) + expect(html).not.toContain(privateValue) + expect(expectInlineConfiguredCss(html)).toContain('--color-primary: #135790;') + } + const unmatched = await request('/no-such-runtime-page', 'application/json') + expect(unmatched.status).toBe(404) + expect(unmatched.headers.get('content-type')).toContain('text/html') + const unmatchedBody = await unmatched.text() + expect(unmatchedBody).toContain('page not found') + expectNoErrorContent(unmatchedBody) + + // Matched loader errors render +error HTML even when JSON was requested. + const matched = await request('/pieces/cutover-kind/missing', 'application/json') + expect(matched.status).toBe(404) + expect(matched.headers.get('content-type')).toContain('text/html') + const matchedBody = await matched.text() + expect(matchedBody).toContain('page not found') + expectNoErrorContent(matchedBody) + for (const pathname of ['/', piecePath, '/rss/pieces/feed.html']) + expectNoErrorContent(await pageHtml(pathname)) + const feeds = await machineFeedSnapshot() + expect(feeds).toEqual(originalMachineFeeds) + for (const feed of Object.values(feeds)) expectNoErrorContent(feed.body) + + const publicApi = await request('/api/pieces?type=cutover-kind', 'application/json') + expect(publicApi.status).toBe(200) + const publicBody = await publicApi.text() + expectNoErrorContent(publicBody) + expect(JSON.parse(publicBody).pieces).toEqual([originalPublicPiece]) + for (const accept of ['text/html', 'application/json']) { + const invalid = await request('/api/pieces?type=unconfigured-kind', accept) + expect(invalid.status).toBe(400) + const body = await invalid.text() + expect(body).toBe('invalid type') + expectNoErrorContent(body) + } + const denied = await request('/admin/open/cutover-kind/real-piece', 'application/json') + expect(denied.status).toBe(403) + expect(denied.headers.get('cache-control')).toBe('private, no-store') + expectNoErrorContent(await denied.text()) + const methodError = await request('/api/pieces', 'application/json', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}' + }) + expect(methodError.status).toBe(405) + expectNoErrorContent(await methodError.text()) + expect(await buildHashes()).toEqual(originalBuild) + }) + test('leaves piece data and every built adapter file unchanged after runtime content/config changes', async () => { expectBuiltIn(await pageHtml()) const response = await request('/api/pieces?type=cutover-kind', 'application/json') diff --git a/apps/web/test/templates/ssr.test.ts b/apps/web/test/templates/ssr.test.ts index 31148c6c..8fc0a83b 100644 --- a/apps/web/test/templates/ssr.test.ts +++ b/apps/web/test/templates/ssr.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { execFile, spawn, type ChildProcess } from 'node:child_process' import { once } from 'node:events' import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' @@ -11,6 +11,8 @@ const exec = promisify(execFile) const fixture = fileURLToPath(new URL('../fixtures/templates/', import.meta.url)) const webRoot = fileURLToPath(new URL('../../', import.meta.url)) let directory: string +let configFilename: string +let childEnv: NodeJS.ProcessEnv let buildDirectory: string let origin: string let server: ChildProcess | undefined @@ -207,7 +209,7 @@ beforeAll(async () => { const cache = path.join(webRoot, 'node_modules/.cache') await mkdir(cache, { recursive: true }) directory = await mkdtemp(path.join(cache, 'template-ssr-')) - const configFilename = path.join(directory, 'config.yaml') + configFilename = path.join(directory, 'config.yaml') await writeFile( configFilename, JSON.stringify({ @@ -309,13 +311,18 @@ beforeAll(async () => { maxBuffer: 2 * 1024 * 1024 }) originalBuild = await buildHashes() + childEnv = env + await startFixture() +}) + +async function startFixture() { const child = spawn( process.execPath, ['--input-type=module', '-e', startServer, path.join(buildDirectory, 'index.js')], { cwd: directory, env: { - ...env, + ...childEnv, HOST: '127.0.0.1', PORT: '0', PROTOCOL_HEADER: 'x-forwarded-proto', @@ -356,20 +363,31 @@ beforeAll(async () => { }) }) origin = `http://127.0.0.1:${port}` -}) +} + +async function stopFixture() { + if (!server || server.exitCode !== null || server.signalCode !== null) return + const exited = once(server, 'exit') + server.kill('SIGTERM') + const timeout = setTimeout(() => server?.kill('SIGKILL'), 5000) + try { + await exited + } finally { + clearTimeout(timeout) + } +} + +async function restartWithErrors(error: object) { + await stopFixture() + const config = JSON.parse(await readFile(configFilename, 'utf8')) + config.pages = { error } + await writeFile(configFilename, JSON.stringify(config)) + await startFixture() +} afterAll(async () => { try { - if (server && server.exitCode === null && server.signalCode === null) { - const exited = once(server, 'exit') - server.kill('SIGTERM') - const timeout = setTimeout(() => server?.kill('SIGKILL'), 5000) - try { - await exited - } finally { - clearTimeout(timeout) - } - } + await stopFixture() } finally { if (directory) await rm(directory, { recursive: true, force: true }) } @@ -528,3 +546,296 @@ describe('stock catalog in built SvelteKit SSR', () => { expect(await buildHashes()).toEqual(originalBuild) }) }) + +async function errorResponse( + route: string, + accept: string | null = 'text/html', + init: RequestInit = {} +) { + const headers = new Headers(init.headers) + if (accept !== null) headers.set('accept', accept) + headers.set('x-forwarded-proto', 'http') + return fetch(`${origin}/${route}`, { + ...init, + headers, + signal: AbortSignal.timeout(10000) + }) +} + +async function errorHtml(kind: number | 'unknown' | 'unmatched', status?: number) { + const route = kind === 'unmatched' ? 'no-such-fixture-page' : `errors/${kind}` + const response = await errorResponse(route) + expect(response.status).toBe(status ?? (typeof kind === 'number' ? kind : 404)) + expect(response.headers.get('content-type')).toContain('text/html') + const html = await response.text() + for (const privateValue of [directory, configFilename, '/private/fixture-secret']) + expect(html).not.toContain(privateValue) + return html +} + +function expectErrorHome(html: string, visible: boolean) { + const rendered = html.replace(/]*>[\s\S]*?<\/script>/g, '') + const homeLinks = rendered.match(/]*\bclass="[^"]*\bhome-button\b[^"]*"[^>]*>/g) ?? [] + expect(homeLinks.length > 0).toBe(visible) +} + +function expectNoErrorPresentation(body: string) { + for (const forbidden of [ + 'Fixture missing copy', + 'Fixture client copy', + 'Fixture server copy', + 'presentation', + directory, + configFilename, + 'sections/error-', + '/private/fixture-secret', + 'Fixture private failure' + ]) + expect(body).not.toContain(forbidden) +} + +describe('configured error pages through the native fixture HTTP server', () => { + test('keeps native expected errors and safe unknown-error defaults when unconfigured', async () => { + for (const status of [404, 400, 403, 500, 503]) { + const html = await errorHtml(status) + expect(html).toContain(`Fixture expected ${status}`) + expectErrorHome(html, true) + } + const unknown = await errorHtml('unknown', 500) + expect(unknown).toContain('Internal Error') + expect(unknown).not.toContain('Fixture private failure') + expectErrorHome(unknown, true) + expect(await errorHtml('unmatched')).toContain('page not found') + }) + + test('selects 404 before 4xx, shares 500 across 5xx without changing status, and sanitizes Markdown', async () => { + await mkdir(path.join(directory, 'sections'), { recursive: true }) + await writeFile( + path.join(directory, 'sections/error-404.md'), + '# Fixture missing copy\n\nTry [the catalog](/pieces).\n\n\n\n[unsafe](javascript:alert(1))\n\n[data link](data:text/html,unsafe)' + ) + await writeFile(path.join(directory, 'sections/error-4xx.md'), '# Fixture client copy') + await writeFile(path.join(directory, 'sections/error-500.md'), '# Fixture server copy') + await restartWithErrors({ + '404': { body: 'sections/error-404.md', home: false }, + '4xx': { body: 'sections/error-4xx.md' }, + '500': { body: 'sections/error-500.md', home: false } + }) + for (const kind of [404, 400, 403, 500, 503, 'unknown', 'unmatched'] as const) { + let status: number + if (typeof kind === 'number') status = kind + else if (kind === 'unknown') status = 500 + else status = 404 + + let selected = 'server' + if (status === 404) selected = 'missing' + else if (status < 500) selected = 'client' + const html = await errorHtml(kind, status) + expect(html).toContain(`

Fixture ${selected} copy

`) + for (const other of ['missing', 'client', 'server'].filter((name) => name !== selected)) + expect(html).not.toContain(`Fixture ${other} copy`) + expectErrorHome(html, selected === 'client') + expect(html).not.toContain('sections/error-') + expect(html).not.toContain('Fixture private failure') + if (status === 404) { + expect(html).toContain('href="/pieces"') + expect(html).toContain('<script>') + expect(html).not.toContain('') + expect(html).not.toMatch(/href=["'](?:javascript|data):/i) + } + } + }) + + test('preserves native error formats and includes presentation only for eligible UI requests', async () => { + for (const accept of ['application/json', 'application/json;q=1, text/html;q=0.5']) { + // Kit renders matched pure-page loader errors as HTML regardless of Accept. + for (const kind of [404, 403, 503, 'unknown'] as const) { + const response = await errorResponse(`errors/${kind}`, accept) + expect(response.status).toBe(kind === 'unknown' ? 500 : kind) + expect(response.headers.get('content-type')).toContain('text/html') + const body = await response.text() + expectNoErrorPresentation(body) + expect(body).toContain(kind === 'unknown' ? 'Internal Error' : `Fixture expected ${kind}`) + if (kind === 404) expect(body).toContain('page not found') + expectErrorHome(body, true) + } + const unmatched = await errorResponse('no-such-fixture-page', accept) + expect(unmatched.status).toBe(404) + expect(unmatched.headers.get('content-type')).toContain('text/html') + const missingBody = await unmatched.text() + expect(missingBody).toContain('page not found') + expectNoErrorPresentation(missingBody) + + const endpoint = await errorResponse('errors/endpoint', accept) + expect(endpoint.status).toBe(503) + expect(endpoint.headers.get('content-type')).toContain('application/json') + const endpointBody = await endpoint.text() + expectNoErrorPresentation(endpointBody) + expect(JSON.parse(endpointBody).message).toBe('Fixture endpoint unavailable') + } + const data = await errorResponse('errors/403/__data.json', 'application/json') + expect(data.headers.get('content-type')).toContain('application/json') + const dataBody = await data.text() + expect(JSON.parse(dataBody)).toMatchObject({ + type: 'data', + nodes: expect.arrayContaining([ + expect.objectContaining({ + type: 'error', + error: expect.objectContaining({ + status: 403, + message: 'Fixture expected 403', + presentation: { + html: expect.stringContaining('

Fixture client copy

'), + home: true + } + }) + }) + ]) + }) + for (const privateValue of [ + directory, + configFilename, + 'sections/error-', + '/private/fixture-secret' + ]) + expect(dataBody).not.toContain(privateValue) + + const action = await errorResponse('errors/403', 'application/json', { + method: 'POST', + headers: { + origin, + 'content-type': 'application/x-www-form-urlencoded', + 'x-sveltekit-action': 'true' + }, + body: '' + }) + expect(action.status).toBe(403) + const actionBody = await action.text() + expect(JSON.parse(actionBody)).toMatchObject({ + type: 'error', + error: { status: 403, message: 'Fixture action denied' } + }) + expect(actionBody).toContain('Fixture action denied') + expectNoErrorPresentation(actionBody) + + for (const accept of [null, '*/*', 'application/json']) { + const response = await errorResponse('errors/403', accept, { + method: 'POST', + headers: { origin, 'content-type': 'application/x-www-form-urlencoded' }, + body: '' + }) + expect(response.status).toBe(403) + expect(response.headers.get('content-type')).toContain('application/json') + const body = await response.text() + expect(JSON.parse(body)).toMatchObject({ + type: 'error', + error: { status: 403, message: 'Fixture action denied' } + }) + expect(body).toContain('Fixture action denied') + expectNoErrorPresentation(body) + } + + const htmlAction = await errorResponse('errors/403', 'text/html', { + method: 'POST', + headers: { origin, 'content-type': 'application/x-www-form-urlencoded' }, + body: '' + }) + expect(htmlAction.status).toBe(403) + expect(htmlAction.headers.get('content-type')).toContain('text/html') + expect(await htmlAction.text()).toContain('

Fixture client copy

') + + const endpoint = await errorResponse('errors/endpoint', 'text/html') + expect(endpoint.status).toBe(503) + expect(endpoint.headers.get('content-type')).toContain('text/html') + expectNoErrorPresentation(await endpoint.text()) + }) + + test('keeps 404 independent of 4xx and supports home-only overrides without inheriting its body', async () => { + await restartWithErrors({ '4xx': { body: 'sections/error-4xx.md', home: false } }) + for (const kind of [404, 'unmatched'] as const) { + const html = await errorHtml(kind) + expect(html).toContain('page not found') + expect(html).not.toContain('Fixture client copy') + expectErrorHome(html, true) + } + const forbidden = await errorHtml(403) + expect(forbidden).toContain('

Fixture client copy

') + expectErrorHome(forbidden, false) + await restartWithErrors({ + '404': { home: false }, + '4xx': { body: 'sections/error-4xx.md', home: false }, + '500': { home: false } + }) + const notFound = await errorHtml(404) + expect(notFound).toContain('page not found') + expect(notFound).toContain('Fixture expected 404') + expect(notFound).not.toContain('Fixture client copy') + expectErrorHome(notFound, false) + const failed = await errorHtml(503) + expect(failed).toContain('Fixture expected 503') + expectErrorHome(failed, false) + }) + + test('falls back safely when the selected Markdown is missing or cannot be read as a file', async () => { + // A directory gives a deterministic read failure without relying on chmod or the test UID. + await mkdir(path.join(directory, 'sections/error-directory'), { recursive: true }) + await restartWithErrors({ + '404': { body: 'sections/error-missing.md', home: false }, + '4xx': { body: 'sections/error-directory' }, + '500': { body: 'sections/error-missing-server.md' } + }) + for (const status of [404, 400, 403, 500, 503]) { + const html = await errorHtml(status) + expect(html).toContain(`Fixture expected ${status}`) + expectErrorHome(html, status !== 404) + expect(html).not.toContain('sections/error-') + expect(html).not.toContain('Unable to load Markdown') + } + const unknown = await errorHtml('unknown', 500) + expect(unknown).toContain('Internal Error') + expect(unknown).not.toContain('Fixture private failure') + expect(await request()).toContain('Catalog title') + await writeFile(path.join(directory, 'sections/error-missing.md'), '# Recovered missing copy') + expect(await errorHtml(404)).not.toContain('Recovered missing copy') + await stopFixture() + await startFixture() + const recovered = await errorHtml(404) + expect(recovered).toContain('

Recovered missing copy

') + expectErrorHome(recovered, false) + }) + + test('loads only the selected file lazily and keeps shared cache/config fixed across atomic replacements until restart', async () => { + const shared = path.join(directory, 'sections/error-shared.md') + const separate = path.join(directory, 'sections/error-separate.md') + await writeFile(shared, '# Before startup') + await writeFile(separate, '# Server before startup') + await writeFile(path.join(directory, 'sections/error-new.md'), '# Restart configuration copy') + await restartWithErrors({ + '404': { body: 'sections/error-shared.md' }, + '4xx': { body: 'sections/error-shared.md' }, + '500': { body: 'sections/error-separate.md' } + }) + await writeFile(shared, '# First selected copy') + expect(await errorHtml(404)).toContain('

First selected copy

') + await writeFile(`${shared}.next`, '# Atomic replacement copy') + await rename(`${shared}.next`, shared) + await writeFile(separate, '# Unselected server copy') + expect(await errorHtml(400)).toContain('

First selected copy

') + expect(await errorHtml(503)).toContain('

Unselected server copy

') + + const config = JSON.parse(await readFile(configFilename, 'utf8')) + config.pages.error['404'] = { body: 'sections/error-new.md', home: false } + await writeFile(`${configFilename}.next`, JSON.stringify(config)) + await rename(`${configFilename}.next`, configFilename) + const beforeRestart = await errorHtml(404) + expect(beforeRestart).toContain('

First selected copy

') + expectErrorHome(beforeRestart, true) + await stopFixture() + await startFixture() + const afterRestart = await errorHtml(404) + expect(afterRestart).toContain('

Restart configuration copy

') + expectErrorHome(afterRestart, false) + expect(await errorHtml(400)).toContain('

Atomic replacement copy

') + expect(await buildHashes()).toEqual(originalBuild) + }) +}) diff --git a/docs/web/config.example.yaml b/docs/web/config.example.yaml index 68870407..031642ab 100644 --- a/docs/web/config.example.yaml +++ b/docs/web/config.example.yaml @@ -48,3 +48,15 @@ pieces: # body: ./content/home.md # feed: # about: ./content/feed-about.md +# error: +# "404": # Selected before "4xx"; each slot defaults to {}. +# body: ./content/not-found.md # Omit for built-in copy. +# home: true # Default: show the home link. +# "4xx": # All remaining client errors, independently configured. +# body: ./content/client-error.md +# home: false +# "500": # All server errors; the actual HTTP status is preserved. +# body: ./content/server-error.md +# home: true +# Error Markdown is loaded lazily for the selected slot; failures use built-in copy. +# Restart web after changing error settings or Markdown files. diff --git a/docs/web/config.md b/docs/web/config.md index 021260e3..10e67e3d 100644 --- a/docs/web/config.md +++ b/docs/web/config.md @@ -104,3 +104,18 @@ Omit `ai` to disable generation; otherwise supply `provider: google` and `api_key: '${GOOGLE_API_KEY}'`. [Remote sync](deployment.md#optional-remote-sync) is optional. `assets.salt` defaults to `${LUZZLE_ASSET_SALT:-}`; keep it stable because changing it changes published asset keys and URLs. + +## Error pages + +Customize three independent presentations under `pages.error`: `"404"` is selected +before `"4xx"` (all remaining client errors); it does not inherit `"4xx"` copy. +`"500"` presents every server error without changing its actual HTTP status. +Each slot defaults to `{}`: optional +`body` is a nonempty, config-relative Markdown filename, and `home` defaults to +`true` to show the recovery link. Omit `body` for built-in copy; set `home: false` +to hide that link without removing global navigation. The [example](config.example.yaml) shows all three. + +Only the selected Markdown file is loaded, lazily. If it cannot be loaded or +rendered, built-in copy is used instead. Restart web after changing these settings +or files. Bodies are ordinary Markdown, not templates; environment references in +configured filenames still use the shared config expansion described above. diff --git a/packages/web/config/src/lib/config/pages.test.ts b/packages/web/config/src/lib/config/pages.test.ts index c9db7cbd..8808b648 100644 --- a/packages/web/config/src/lib/config/pages.test.ts +++ b/packages/web/config/src/lib/config/pages.test.ts @@ -1,10 +1,18 @@ import { expect, test } from "vitest"; import { validateConfig } from "./config.js"; -test("site pages have flat default settings and no default feed explanation", () => { +const defaultErrors = { + "404": { home: true }, + "4xx": { home: true }, + "500": { home: true }, +}; +const errorSlots = ["404", "4xx", "500"] as const; + +test("site pages default without Markdown bodies or a feed explanation", () => { expect(validateConfig({}, {}).pages).toEqual({ home: { latest: true, random: false }, feed: { links: true }, + error: defaultErrors, }); }); @@ -12,10 +20,61 @@ test("site sections accept Markdown file references and independent booleans", ( const pages = { home: { body: "./home.md", latest: false, random: true }, feed: { about: "./feed.md", links: false }, + error: { + "404": { body: "./not-found.md", home: false }, + "4xx": { body: "./client-error.md", home: true }, + "500": { body: "./server-error.md", home: false }, + }, }; expect(validateConfig({ pages }, {}).pages).toEqual(pages); }); +test.each([{}, { error: {} }, { error: { "404": {}, "4xx": {}, "500": {} } }])( + "defaults empty error settings independently: %j", + (pages) => { + expect(validateConfig({ pages }, {}).pages.error).toEqual(defaultErrors); + }, +); + +test.each(errorSlots)( + "defaults home for a body-only %s presentation", + (slot) => { + expect( + validateConfig( + { pages: { error: { [slot]: { body: "./error.md" } } } }, + {}, + ).pages.error, + ).toEqual({ ...defaultErrors, [slot]: { body: "./error.md", home: true } }); + }, +); + +test.each(errorSlots)("keeps the %s home toggle independent", (slot) => { + for (const home of [false, true]) { + const error = Object.fromEntries( + errorSlots.map((key) => { + const showHome = key === slot ? home : !home; + return [key, { home: showHome }]; + }), + ); + expect(validateConfig({ pages: { error } }, {}).pages.error).toEqual(error); + } +}); + +test("error body filenames use ordinary shared config environment interpolation", () => { + expect( + validateConfig( + { pages: { error: { "404": { body: "${ERROR_BODY}" } } } }, + { ERROR_BODY: "./not-found.md" }, + ).pages.error["404"], + ).toEqual({ body: "./not-found.md", home: true }); + expect(() => + validateConfig( + { pages: { error: { "404": { body: "${ERROR_BODY}" } } } }, + { ERROR_BODY: "" }, + ), + ).toThrow(); +}); + test.each([ { home: { latest: { enabled: true } } }, { home: { body: { markdownFile: "./home.md" } } }, @@ -29,3 +88,33 @@ test.each([ expect(() => validateConfig({ pages }, {})).toThrow(); }, ); + +test.each([null, false, "./error.md", [], { "403": {} }, { "5xx": {} }])( + "rejects malformed error sections and unknown presentation keys: %j", + (error) => { + expect(() => validateConfig({ pages: { error } }, {})).toThrow(); + }, +); + +test.each([ + null, + false, + "./error.md", + [], + { body: "" }, + { body: null }, + { body: true }, + { body: 404 }, + { body: { markdownFile: "./error.md" } }, + { home: null }, + { home: "false" }, + { home: 0 }, + { home: { enabled: true } }, + { unknown: true }, +])("rejects malformed settings in every error presentation: %j", (settings) => { + for (const slot of errorSlots) { + expect(() => + validateConfig({ pages: { error: { [slot]: settings } } }, {}), + ).toThrow(); + } +}); diff --git a/packages/web/config/src/lib/config/schema.ts b/packages/web/config/src/lib/config/schema.ts index 2ba352df..1c975b03 100644 --- a/packages/web/config/src/lib/config/schema.ts +++ b/packages/web/config/src/lib/config/schema.ts @@ -291,6 +291,23 @@ const AssetsSchema = Type.Object( { default: {}, additionalProperties: false }, ); +const ErrorPageSchema = Type.Object( + { + body: Type.Optional( + Type.String({ + minLength: 1, + description: + "Markdown file replacing the error body, relative to the supplied config file.", + }), + ), + home: Type.Boolean({ + default: true, + description: "Show a link back to the home page.", + }), + }, + { default: {}, additionalProperties: false }, +); + const PagesSchema = Type.Object( { home: Type.Object( @@ -329,6 +346,19 @@ const PagesSchema = Type.Object( }, { default: {}, additionalProperties: false }, ), + error: Type.Object( + { + "404": ErrorPageSchema, + "4xx": ErrorPageSchema, + "500": ErrorPageSchema, + }, + { + default: {}, + additionalProperties: false, + description: + "Independent error presentations: 404 takes precedence over remaining 4xx; 500 presents all 5xx without changing the actual status.", + }, + ), }, { default: {}, additionalProperties: false }, ); diff --git a/packages/web/config/src/lib/config/web.config.schema.json b/packages/web/config/src/lib/config/web.config.schema.json index 3f1a2c2f..33b37212 100644 --- a/packages/web/config/src/lib/config/web.config.schema.json +++ b/packages/web/config/src/lib/config/web.config.schema.json @@ -424,6 +424,65 @@ "type": "boolean" } } + }, + "error": { + "default": {}, + "additionalProperties": false, + "description": "Independent error presentations: 404 takes precedence over remaining 4xx; 500 presents all 5xx without changing the actual status.", + "type": "object", + "properties": { + "404": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "body": { + "minLength": 1, + "description": "Markdown file replacing the error body, relative to the supplied config file.", + "type": "string" + }, + "home": { + "default": true, + "description": "Show a link back to the home page.", + "type": "boolean" + } + } + }, + "500": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "body": { + "minLength": 1, + "description": "Markdown file replacing the error body, relative to the supplied config file.", + "type": "string" + }, + "home": { + "default": true, + "description": "Show a link back to the home page.", + "type": "boolean" + } + } + }, + "4xx": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "body": { + "minLength": 1, + "description": "Markdown file replacing the error body, relative to the supplied config file.", + "type": "string" + }, + "home": { + "default": true, + "description": "Show a link back to the home page.", + "type": "boolean" + } + } + } + } } } }, diff --git a/packages/web/templates/config.schema.json b/packages/web/templates/config.schema.json index a6725923..c942e878 100644 --- a/packages/web/templates/config.schema.json +++ b/packages/web/templates/config.schema.json @@ -424,6 +424,65 @@ "type": "boolean" } } + }, + "error": { + "default": {}, + "additionalProperties": false, + "description": "Independent error presentations: 404 takes precedence over remaining 4xx; 500 presents all 5xx without changing the actual status.", + "type": "object", + "properties": { + "404": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "body": { + "minLength": 1, + "description": "Markdown file replacing the error body, relative to the supplied config file.", + "type": "string" + }, + "home": { + "default": true, + "description": "Show a link back to the home page.", + "type": "boolean" + } + } + }, + "500": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "body": { + "minLength": 1, + "description": "Markdown file replacing the error body, relative to the supplied config file.", + "type": "string" + }, + "home": { + "default": true, + "description": "Show a link back to the home page.", + "type": "boolean" + } + } + }, + "4xx": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "body": { + "minLength": 1, + "description": "Markdown file replacing the error body, relative to the supplied config file.", + "type": "string" + }, + "home": { + "default": true, + "description": "Show a link back to the home page.", + "type": "boolean" + } + } + } + } } } }, -- 2.54.0 From db6d20767431f100ed5816efa1727f367ede3263 Mon Sep 17 00:00:00 2001 From: eleith Date: Sun, 1 Nov 2026 10:49:55 -0800 Subject: [PATCH 2/2] [web] fix Kit 3 dev startup and enable demo error pages --- .changeset/kit3-dev-generated-directory.md | 9 +++++++++ apps/web/Dockerfile | 4 ++-- apps/web/demo/config/config.yaml | 10 ++++++++++ apps/web/demo/content/error-404.md | 11 +++++++++++ apps/web/demo/content/error-4xx.md | 9 +++++++++ apps/web/demo/content/error-500.md | 12 ++++++++++++ 6 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 .changeset/kit3-dev-generated-directory.md create mode 100644 apps/web/demo/content/error-404.md create mode 100644 apps/web/demo/content/error-4xx.md create mode 100644 apps/web/demo/content/error-500.md diff --git a/.changeset/kit3-dev-generated-directory.md b/.changeset/kit3-dev-generated-directory.md new file mode 100644 index 00000000..f6ca5e4a --- /dev/null +++ b/.changeset/kit3-dev-generated-directory.md @@ -0,0 +1,9 @@ +--- +"@luzzle/web": patch +--- + +Prepare SvelteKit 3's generated node_modules/$app directory for the configured +non-root development user, without widening dependency-directory permissions +or changing the production image. + +Enable distinct demo Markdown overrides for 404, remaining 4xx, and server errors. diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 2e43aa77..84870262 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -15,12 +15,12 @@ RUN pnpm install --frozen-lockfile RUN pnpm --filter @luzzle/core --filter @luzzle/web.config --filter @luzzle/web.theme --filter @luzzle/web.db --filter @luzzle/web.jobs --filter @luzzle/web.pieces --filter @luzzle/web.templates build WORKDIR /m/apps/web RUN mkdir -p assets/pieces assets/build data content build config \ - node_modules/.cache node_modules/.vite .svelte-kit && \ + node_modules/.cache node_modules/.vite 'node_modules/$app' .svelte-kit && \ touch config/config.yaml .env && \ ln -sfn config/config.yaml config.yaml && \ chmod 777 . config/config.yaml .env package.json && \ chmod -R 777 assets data content build \ - node_modules/.cache node_modules/.vite .svelte-kit + node_modules/.cache node_modules/.vite 'node_modules/$app' .svelte-kit EXPOSE 3000 ENTRYPOINT ["node", "scripts/manager.js"] diff --git a/apps/web/demo/config/config.yaml b/apps/web/demo/config/config.yaml index 0e17066f..aae0c8b2 100644 --- a/apps/web/demo/config/config.yaml +++ b/apps/web/demo/config/config.yaml @@ -46,6 +46,16 @@ pages: feed: about: ./content/feed-about.md links: true + error: + '404': + body: ./content/error-404.md + home: false + '4xx': + body: ./content/error-4xx.md + home: true + '500': + body: ./content/error-500.md + home: true pieces: - type: books diff --git a/apps/web/demo/content/error-404.md b/apps/web/demo/content/error-404.md new file mode 100644 index 00000000..07daeee2 --- /dev/null +++ b/apps/web/demo/content/error-404.md @@ -0,0 +1,11 @@ +# Nothing growing here + +This is the demo’s custom **404 page**, written in ordinary Markdown. + +The piece you were looking for may have moved, or this path may never have existed. + +- Check the address for a typo. +- Explore the [books](/pieces/books) or [films](/pieces/films). +- [Browse all pieces](/pieces) and find another trail. + +> This override hides the built-in **go home** button. The navigation above is still available. diff --git a/apps/web/demo/content/error-4xx.md b/apps/web/demo/content/error-4xx.md new file mode 100644 index 00000000..bbacbd5d --- /dev/null +++ b/apps/web/demo/content/error-4xx.md @@ -0,0 +1,9 @@ +# Let’s try that request again + +This is the demo’s custom **4xx page**. The distinct 404 page does not use this copy. + +The server could not accept this request. Check what you entered and try again. + +> A small detour is not the end of the trail. + +You can [browse the garden](/pieces), or use the built-in **go home** button below. diff --git a/apps/web/demo/content/error-500.md b/apps/web/demo/content/error-500.md new file mode 100644 index 00000000..6db4c978 --- /dev/null +++ b/apps/web/demo/content/error-500.md @@ -0,0 +1,12 @@ +# The garden needs a moment + +This is the demo’s custom **server-error page**, shared by 500 and other 5xx responses. + +Something went wrong while handling your request. Please try again shortly. + +If it keeps happening: + +1. Note which page you were visiting. +2. Let the site owner know. + +Private error details stay on the server. The built-in **go home** button remains available below. -- 2.54.0