[web] add Markdown-customizable error pages #177
No files matched your search
@@ -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
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Vendored
+4
-1
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 <detail> & 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 <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')
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export type ErrorPresentation = {
|
||||
/** Trusted server-rendered Markdown only; never arbitrary author HTML. */
|
||||
html?: string
|
||||
home: boolean
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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']])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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('<script>')
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user