[web] add Markdown-customizable error pages #177

Merged
eleith merged 2 commits from feat/error-markdown into main 2026-11-01 18:56:16 +00:00
33 changed files with 1290 additions and 55 deletions

No files matched your search

+9
View File
@@ -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.
@@ -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.
+2 -2
View File
@@ -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"]
+10
View File
@@ -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
+11
View File
@@ -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.
+9
View File
@@ -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.
+12
View File
@@ -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.
+4 -1
View File
@@ -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<Session | null>
+10
View File
@@ -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')
+2
View File
@@ -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)
@@ -1,36 +1,40 @@
<script lang="ts">
import { browser } from '$app/env'
import { page } from '$app/state'
const meta = $derived(page.data.meta ?? {})
const content = $derived(page.data.config?.content.text)
const assets = $derived(page.data.config?.url.app_assets ?? '')
</script>
<svelte:head>
{#if page.data.meta.title}
<title>{page.data.meta.title}</title>
<meta property="og:title" content={page.data.meta.title} />
{#if meta.title}
<title>{meta.title}</title>
<meta property="og:title" content={meta.title} />
{:else}
<title>{page.data.config.content.text.title}</title>
<meta property="og:title" content={page.data.config.content.text.title} />
<title>{content?.title ?? 'Luzzle Explorer'}</title>
<meta property="og:title" content={content?.title ?? 'Luzzle Explorer'} />
{/if}
{#if page.data.meta.description}
<meta name="description" content={page.data.meta.description} />
<meta property="og:description" content={page.data.meta.description} />
{#if meta.description}
<meta name="description" content={meta.description} />
<meta property="og:description" content={meta.description} />
{:else}
<meta name="description" content={page.data.config.content.text.description} />
<meta property="og:description" content={page.data.config.content.text.description} />
<meta name="description" content={content?.description ?? ''} />
<meta property="og:description" content={content?.description ?? ''} />
{/if}
{#if page.data.meta.image}
<meta property="og:image" content={page.data.meta.image} />
{#if meta.image}
<meta property="og:image" content={meta.image} />
{:else}
<meta property="og:image" content="{page.data.config.url.app_assets}/images/opengraph.png" />
<meta property="og:image" content="{assets}/images/opengraph.png" />
{/if}
{#if page.data.meta.type}
<meta property="og:type" content={page.data.meta.type} />
{#if meta.type}
<meta property="og:type" content={meta.type} />
{:else}
<meta property="og:type" content="website" />
{/if}
<meta property="og:url" content={page.url.href} />
<meta property="og:locale" content="en_US" />
<link rel="icon" href="{page.data.config.url.app_assets}/images/favicon.png" />
<link rel="icon" href="{assets}/images/favicon.png" />
{#if !browser}
<script>
;(function () {
@@ -1,8 +1,14 @@
<script lang="ts">
import { resolve } from '$app/paths'
import ContentBanner from './ContentBanner.svelte'
import Markdown from '#lib/content/markdown/Markdown.svelte'
let { message }: { message?: string } = $props()
type Props = {
message?: string
html?: string
home?: boolean
}
let { message, html, home = true }: Props = $props()
</script>
<ContentBanner showHome={true} />
@@ -10,10 +16,22 @@
<main id="main-content" class="error-page">
<section class="section">
<div class="error-card">
<h1>404</h1>
<h2>page not found</h2>
<p>{message || 'there are no pieces at this path.'}</p>
<a href={resolve('')} class="home-button">go home</a>
{#if html !== undefined}
<Markdown {html} />
{:else}
<h1>404</h1>
<h2>page not found</h2>
<p>
{#if message}
{message}
{:else}
there are no pieces at this path.
{/if}
</p>
{/if}
{#if home}
<a href={resolve('')} class="home-button">go home</a>
{/if}
</div>
</section>
</main>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { ErrorProps } from '../../../routes/$types'
import Page404Default from './404.default.svelte'
import PageErrorDefault from './error.default.svelte'
let { error }: ErrorProps = $props()
</script>
{#if error.status === 404}
<Page404Default
message={error.message}
html={error.presentation?.html}
home={error.presentation?.home ?? true}
/>
{:else}
<PageErrorDefault
status={error.status}
message={error.message}
html={error.presentation?.html}
home={error.presentation?.home ?? true}
/>
{/if}
@@ -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(/<!--[\s\S]*?-->/g, '')
}
function main(output: string) {
const body = output.match(/<main\b[^>]*>([\s\S]*?)<\/main>/)?.[1]
expect(body).toBeDefined()
return body!
}
const message = 'Safe native message <detail> & 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(`<h1[^>]*>${status === 404 ? '404' : `error ${status}`}</h1>`))
expect(body.includes('page not found')).toBe(status === 404)
expect(body).toContain('Safe native message &lt;detail> &amp; context')
expect(body).not.toContain('<detail>')
expect(body).not.toContain('class="markdown"')
expect(body).toMatch(/<a href="\/garden\/" class="home-button[^"\n]*">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(/<h1[^>]*>(?: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(/<a href="\/garden\/" aria-label="main page">/)
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(/<h[12]\b[^>]*>(?:404|error \d+|page not found)/)
if (html === '') expect(body).not.toMatch(/<h[12]\b|<p\b/)
}
}
})
test('empty Markdown still allows an independent recovery link', () => {
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 &lt;detail> &amp; 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')
})
@@ -1,9 +1,16 @@
<script lang="ts">
import { resolve } from '$app/paths'
import ContentBanner from './ContentBanner.svelte'
import Markdown from '#lib/content/markdown/Markdown.svelte'
type Props = { status: number; message?: string }
let { status, message = 'an unexpected error occurred' }: Props = $props()
type Props = {
status: number
message?: string
/** Trusted server-rendered Markdown only; never arbitrary author HTML. */
html?: string
home?: boolean
}
let { status, message, html, home = true }: Props = $props()
</script>
<ContentBanner showHome={true} />
@@ -11,9 +18,21 @@
<main id="main-content" class="error-page">
<section class="section">
<div class="error-card">
<h1>error {status}</h1>
<p>{message}</p>
<a href={resolve('')} class="home-button">go home</a>
{#if html !== undefined}
<Markdown {html} />
{:else}
<h1>error {status}</h1>
<p>
{#if message !== undefined}
{message}
{:else}
an unexpected error occurred
{/if}
</p>
{/if}
{#if home}
<a href={resolve('')} class="home-button">go home</a>
{/if}
</div>
</section>
</main>
+5
View File
@@ -0,0 +1,5 @@
export type ErrorPresentation = {
/** Trusted server-rendered Markdown only; never arbitrary author HTML. */
html?: string
home: boolean
}
+227
View File
@@ -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('<h1>Authored error</h1>')
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: '<h1>Authored error</h1>', 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: '<h1>Authored error</h1>' }
})
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: '<h1>Authored error</h1>', 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<HandleServerError>[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 }
})
})
+52
View File
@@ -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<string>(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 }
}
}
+6
View File
@@ -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']])
+6 -3
View File
@@ -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<string | undefined> {
export function loadPageMarkdown(
file?: string,
configFilename = config.paths.config
): Promise<string | undefined> {
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)
}
+2 -7
View File
@@ -1,13 +1,8 @@
<script lang="ts">
import type { ErrorProps } from './$types'
import Page404Default from '#lib/content/components/404.default.svelte'
import PageErrorDefault from '#lib/content/components/error.default.svelte'
import ErrorPage from '#lib/content/components/ErrorPage.svelte'
let { error }: ErrorProps = $props()
</script>
{#if error.status === 404}
<Page404Default message={error.message} />
{:else}
<PageErrorDefault status={error.status} message={error.message} />
{/if}
<ErrorPage {error} />
+8 -1
View File
@@ -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)
}
@@ -0,0 +1,7 @@
<script>
import ErrorPage from '#lib/content/components/ErrorPage.svelte'
let { error } = $props()
</script>
<ErrorPage {error} />
@@ -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')
}
}
@@ -0,0 +1,5 @@
import { error } from '@sveltejs/kit'
export function GET() {
error(503, 'Fixture endpoint unavailable')
}
+75
View File
@@ -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('<h1>Runtime missing copy</h1>')
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(/<a\b[^>]*\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')
+325 -14
View File
@@ -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(/<script\b[^>]*>[\s\S]*?<\/script>/g, '')
const homeLinks = rendered.match(/<a\b[^>]*\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<script>window.errorInjected=1</script>\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(`<h1>Fixture ${selected} copy</h1>`)
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('&lt;script&gt;')
expect(html).not.toContain('<script>window.errorInjected=1</script>')
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('<h1>Fixture client copy</h1>'),
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('<h1>Fixture client copy</h1>')
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('<h1>Fixture client copy</h1>')
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('<h1>Recovered missing copy</h1>')
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('<h1>First selected copy</h1>')
await writeFile(`${shared}.next`, '# Atomic replacement copy')
await rename(`${shared}.next`, shared)
await writeFile(separate, '# Unselected server copy')
expect(await errorHtml(400)).toContain('<h1>First selected copy</h1>')
expect(await errorHtml(503)).toContain('<h1>Unselected server copy</h1>')
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('<h1>First selected copy</h1>')
expectErrorHome(beforeRestart, true)
await stopFixture()
await startFixture()
const afterRestart = await errorHtml(404)
expect(afterRestart).toContain('<h1>Restart configuration copy</h1>')
expectErrorHome(afterRestart, false)
expect(await errorHtml(400)).toContain('<h1>Atomic replacement copy</h1>')
expect(await buildHashes()).toEqual(originalBuild)
})
})
+12
View File
@@ -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.
+15
View File
@@ -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.
@@ -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();
}
});
@@ -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 },
);
@@ -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"
}
}
}
}
}
}
},
+59
View File
@@ -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"
}
}
}
}
}
}
},