Configure site favicon and default Open Graph image #181

Merged
eleith merged 1 commits from feat/site-image-overrides into main 2026-11-02 20:21:56 +00:00
39 changed files with 1731 additions and 87 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Allow runtime configuration of the site favicon and default Open Graph image without replacing bundled static files. Piece-specific Open Graph images remain unchanged.
@@ -4,7 +4,7 @@
const meta = $derived(page.data.meta ?? {})
const content = $derived(page.data.config?.content.text)
const assets = $derived(page.data.config?.url.app_assets ?? '')
const images = $derived(page.data.config?.content.images)
</script>
<svelte:head>
@@ -24,8 +24,8 @@
{/if}
{#if meta.image}
<meta property="og:image" content={meta.image} />
{:else}
<meta property="og:image" content="{assets}/images/opengraph.png" />
{:else if images}
<meta property="og:image" content={images.opengraph} />
{/if}
{#if meta.type}
<meta property="og:type" content={meta.type} />
@@ -34,7 +34,9 @@
{/if}
<meta property="og:url" content={page.url.href} />
<meta property="og:locale" content="en_US" />
<link rel="icon" href="{assets}/images/favicon.png" />
{#if images}
<link rel="icon" href={images.favicon} />
{/if}
{#if !browser}
<script>
;(function () {
@@ -0,0 +1,114 @@
import { beforeEach, expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import type { AppConfigPublic } from '#lib/server/config.js'
import { makeConfig } from '#lib/server/config.fixture.js'
import { siteImageUrls } from '#lib/server/site-images.js'
import SeoHead from './SeoHead.svelte'
const { page } = vi.hoisted(() => ({
page: {
data: {} as Partial<App.PageData>,
url: new URL('https://request.test/pieces/books/book')
}
}))
vi.mock('$app/state', () => ({ page }))
vi.mock('$app/env', () => ({ browser: false }))
function configuration(appAssets = '/static', app = 'https://public.test'): AppConfigPublic {
const config = makeConfig()
config.url = { app, app_assets: appAssets, luzzle_assets: '/pieces-cdn' }
return {
content: {
text: { title: 'Garden', description: 'A garden' },
images: siteImageUrls(config, page.url)
},
url: config.url
}
}
beforeEach(() => {
page.url = new URL('https://request.test/pieces/books/book')
page.data = { config: configuration() }
})
test.each([
['', '/images/favicon.png', 'https://public.test/images/opengraph.png'],
['/static', '/static/images/favicon.png', 'https://public.test/static/images/opengraph.png'],
['/static/', '/static/images/favicon.png', 'https://public.test/static/images/opengraph.png'],
[
'https://cdn.test/static',
'https://cdn.test/static/images/favicon.png',
'https://cdn.test/static/images/opengraph.png'
]
])('consumes server-resolved bundled images with asset prefix %j', (prefix, favicon, image) => {
page.data.config = configuration(prefix)
const { head } = render(SeoHead)
expect(head).toContain(`rel="icon" href="${favicon}"`)
expect(head).toContain(`property="og:image" content="${image}"`)
expect(head).toContain('property="og:url" content="https://request.test/pieces/books/book"')
})
test.each([
['/static/images/favicon.png', 'https://public.test/static/images/opengraph.png'],
['/site-assets/favicon', 'https://public.test/static/images/opengraph.png'],
['/static/images/favicon.png', 'https://public.test/site-assets/opengraph'],
['/site-assets/favicon', 'https://public.test/site-assets/opengraph']
])('consumes resolved favicon %s and OG %s independently', (favicon, opengraph) => {
const config = configuration()
config.content.images = { favicon, opengraph }
page.data.config = config
const { head } = render(SeoHead)
expect(head).toContain(`rel="icon" href="${favicon}"`)
expect(head).toContain(`property="og:image" content="${opengraph}"`)
expect(head.match(/rel="icon"/g)).toHaveLength(1)
expect(head.match(/property="og:image"/g)).toHaveLength(1)
expect(head).not.toContain('/pieces-cdn/')
})
test.each(['', '/garden'])('consumes server-resolved OG for relative app base %j', (app) => {
page.data.config = configuration('/static', app)
expect(render(SeoHead).head).toContain(
'property="og:image" content="https://request.test/static/images/opengraph.png"'
)
})
test.each([
'/pieces/assets/book/share.png',
'https://pieces.test/pieces/assets/book/share%20image.png'
])('piece OG %j retains priority and is not absolutized or rewritten', (image) => {
const config = configuration()
config.content.images = {
favicon: '/site-assets/favicon',
opengraph: 'https://public.test/site-assets/opengraph'
}
page.data = { config, meta: { title: 'Book | Garden', image } }
const { head } = render(SeoHead)
expect(head).toContain(`property="og:image" content="${image}"`)
expect(head).toContain('rel="icon" href="/site-assets/favicon"')
expect(head).not.toContain('site-assets/opengraph')
expect(head).toContain('<title>Book | Garden</title>')
})
test('head escapes metadata and resolved image URLs', () => {
const config = configuration()
config.content.images = {
favicon: '/site-assets/favicon?a=1&b="icon"',
opengraph: 'https://public.test/site-assets/opengraph?a=1&b=2'
}
page.data = { config, meta: { title: '<Book> & Garden', description: 'A "quote" & detail' } }
const { head } = render(SeoHead)
expect(head).toContain('<title>&lt;Book> &amp; Garden</title>')
expect(head).toContain('content="A &quot;quote&quot; &amp; detail"')
expect(head).toContain('href="/site-assets/favicon?a=1&amp;b=&quot;icon&quot;"')
expect(head).toContain('content="https://public.test/site-assets/opengraph?a=1&amp;b=2"')
})
test('absent root layout data renders safely without inventing site-image selections', () => {
page.data = {}
page.url = new URL('https://request.test:8443/missing')
const { head } = render(SeoHead)
expect(head).toContain('<title>Luzzle Explorer</title>')
expect(head).not.toContain('rel="icon"')
expect(head).not.toContain('property="og:image"')
expect(head).not.toMatch(/undefined|null|private/)
})
@@ -1,6 +1,7 @@
import { render } from 'svelte/server'
import { expect, test, vi } from 'vitest'
import { renderMarkdown } from '#lib/content/markdown/render.server.js'
import { makePublicConfig } from '#lib/server/config.fixture.js'
import ErrorPage from './ErrorPage.svelte'
import Page404Default from './404.default.svelte'
import PageErrorDefault from './error.default.svelte'
@@ -14,13 +15,15 @@ vi.mock('$app/state', () => ({
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' }
const config = makePublicConfig({
url: {
app: 'https://garden.test',
app_assets: 'https://assets.test',
luzzle_assets: ''
}
}
})
config.content.text = { title: 'Garden', description: 'A garden' }
return { meta: {}, config }
}
},
navigating: { to: null }
+20 -1
View File
@@ -1,10 +1,29 @@
import { loadConfig, type Config } from '@luzzle/web.config'
import { loadConfig, type Config, type ConfigPublic } from '@luzzle/web.config'
import { siteImageUrls } from './site-images.js'
/** Complete resolved configuration for server tests, without reading deployment YAML. */
export function makeConfig(overrides: Partial<Config> = {}): Config {
return { ...loadConfig(), auth: undefined, ai: undefined, ...overrides }
}
/** Public layout data for component tests; never copy private image selections. */
export function makePublicConfig(overrides: Partial<ConfigPublic> = {}): ConfigPublic {
const config = makeConfig()
const url = overrides.url ?? config.url
return {
content: {
text: config.content.text,
images: siteImageUrls({ ...config, url }, new URL('http://localhost'))
},
url: {
app: url.app,
app_assets: url.app_assets,
luzzle_assets: url.luzzle_assets
},
...overrides
}
}
export const credentialsAuth = {
secret: 'test-session-secret-with-at-least-32-characters',
credentials: { username: 'admin', password: 'test-password' }
+273
View File
@@ -0,0 +1,273 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, rm, symlink, truncate, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { siteImageDefaults, validateConfig } from '@luzzle/web.config'
import { makeConfig } from './config.fixture.js'
import { createSiteImageHandler, MAX_SITE_IMAGE_BYTES, siteImageUrls } from './site-images.js'
import type { SiteImage } from '#lib/site-images.js'
let directory: string
beforeEach(async () => {
const cache = path.resolve('node_modules/.cache')
await mkdir(cache, { recursive: true })
directory = await mkdtemp(path.join(cache, 'site-images-'))
})
afterEach(async () => {
await rm(directory, { recursive: true, force: true })
})
function configuration(images?: Partial<Record<SiteImage, string>>) {
const config = makeConfig()
config.content.images = validateConfig(images ? { content: { images } } : {}, {}).content.images
config.paths.config = path.join(directory, 'config.yaml')
return config
}
function request(method = 'GET', headers: HeadersInit = {}) {
return new Request('https://garden.test/site-assets/favicon?file=/private/other.png', {
method,
headers
})
}
async function expectNotFound(response: Response, method = 'GET') {
expect(response.status).toBe(404)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(response.headers.get('content-length')).toBe('9')
expect(response.headers.get('x-content-type-options')).toBe('nosniff')
expect(await response.text()).toBe(method === 'HEAD' ? '' : 'Not found')
}
describe('selected image responses', () => {
test.each([
['favicon', 'png', 'image/png'],
['favicon', 'SVG', 'image/svg+xml'],
['favicon', 'ico', 'image/x-icon'],
['opengraph', 'png', 'image/png'],
['opengraph', 'jpg', 'image/jpeg'],
['opengraph', 'jpeg', 'image/jpeg'],
['opengraph', 'webp', 'image/webp'],
['opengraph', 'gif', 'image/gif']
] as const)('serves selected %s .%s bytes with %s metadata', async (role, extension, mime) => {
const bytes = Buffer.from('selected image bytes with café')
const filename = `private family.${extension}`
await writeFile(path.join(directory, filename), bytes)
const serve = createSiteImageHandler(configuration({ [role]: filename }))
const etag = `"${createHash('sha256').update(bytes).digest('base64url')}"`
for (const method of ['GET', 'HEAD']) {
const response = await serve(role, request(method))
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe(mime)
expect(response.headers.get('content-length')).toBe(String(bytes.length))
expect(response.headers.get('cache-control')).toBe('public, max-age=0, must-revalidate')
expect(response.headers.get('x-content-type-options')).toBe('nosniff')
expect(response.headers.get('etag')).toBe(etag)
expect(Buffer.from(await response.arrayBuffer())).toEqual(
method === 'HEAD' ? Buffer.alloc(0) : bytes
)
if (method === 'HEAD') expect(response.body).toBeNull()
const otherRole = role === 'favicon' ? 'opengraph' : 'favicon'
await expectNotFound(await serve(otherRole, request(method)), method)
}
})
test('uses supplied config filename rather than the symlink target or working directory', async () => {
await mkdir(path.join(directory, 'config'))
await mkdir(path.join(directory, 'content'))
await writeFile(path.join(directory, 'config/config.yaml'), '')
await symlink('config/config.yaml', path.join(directory, 'config.yaml'))
await writeFile(path.join(directory, 'content/icon.png'), 'correct')
const serve = createSiteImageHandler(configuration({ favicon: './content/icon.png' }))
expect(await (await serve('favicon', request())).text()).toBe('correct')
})
test('accepts an absolute selected filename without a supplied config path', async () => {
const filename = path.join(directory, 'icon.png')
await writeFile(filename, 'absolute')
const config = configuration({ favicon: filename })
delete config.paths.config
expect(await (await createSiteImageHandler(config)('favicon', request())).text()).toBe(
'absolute'
)
})
test('returns 304 for current strong, weak, list, and wildcard validators, but not stale ones', async () => {
await writeFile(path.join(directory, 'icon.png'), 'current')
const serve = createSiteImageHandler(configuration({ favicon: 'icon.png' }))
const etag = (await serve('favicon', request())).headers.get('etag')!
for (const validator of [etag, `W/${etag}`, `"old", W/${etag}`, '*']) {
for (const method of ['GET', 'HEAD']) {
const response = await serve('favicon', request(method, { 'if-none-match': validator }))
expect(response.status).toBe(304)
expect(response.body).toBeNull()
expect(response.headers.get('etag')).toBe(etag)
expect(response.headers.has('content-length')).toBe(false)
}
}
expect((await serve('favicon', request('GET', { 'if-none-match': '"old"' }))).status).toBe(200)
})
test('ignores ranges and stale compressed siblings, regardless of request source hints', async () => {
await writeFile(path.join(directory, 'icon.png'), 'current')
await writeFile(path.join(directory, 'icon.png.gz'), 'old gzip')
await writeFile(path.join(directory, 'icon.png.br'), 'old brotli')
const serve = createSiteImageHandler(configuration({ favicon: 'icon.png' }))
const response = await serve(
'favicon',
request('GET', {
'accept-encoding': 'br, gzip',
range: 'bytes=0-1',
'x-image-source': '/private/other.png'
})
)
expect(response.status).toBe(200)
expect(response.headers.get('content-length')).toBe('7')
for (const header of ['content-encoding', 'content-range', 'accept-ranges']) {
expect(response.headers.has(header)).toBe(false)
}
expect(await response.text()).toBe('current')
})
test('retains selected bytes and ETags until a new handler is created', async () => {
const filename = path.join(directory, 'icon.png')
await writeFile(filename, 'first')
const config = configuration({ favicon: 'icon.png' })
const serve = createSiteImageHandler(config)
const first = await serve('favicon', request())
const etag = first.headers.get('etag')!
await writeFile(filename, 'other')
expect(await (await serve('favicon', request())).text()).toBe('first')
const restarted = await createSiteImageHandler(config)(
'favicon',
request('GET', { 'if-none-match': etag })
)
expect(restarted.status).toBe(200)
expect(restarted.headers.get('etag')).not.toBe(etag)
expect(await restarted.text()).toBe('other')
})
test.each([undefined, {}, siteImageDefaults])(
'returns no-store GET/HEAD 404s for built-in selections %j',
async (images) => {
const serve = createSiteImageHandler(configuration(images))
for (const role of ['favicon', 'opengraph'] as const) {
for (const method of ['GET', 'HEAD']) {
await expectNotFound(await serve(role, request(method)), method)
}
}
}
)
test('does not read built-in roles or accept an unknown role', async () => {
const config = configuration()
delete config.paths.config
const serve = createSiteImageHandler(config)
for (const role of [
'favicon',
'opengraph',
'other',
'../favicon',
'constructor',
'__proto__'
]) {
await expectNotFound(await serve(role, request()))
}
})
test.each(['missing.png', 'empty.png', 'directory.png', 'large.png', 'pipe.png'])(
'returns safe no-store GET/HEAD 404s for %s',
async (filename) => {
await writeFile(path.join(directory, 'empty.png'), '')
await mkdir(path.join(directory, 'directory.png'))
await writeFile(path.join(directory, 'large.png'), '')
await truncate(path.join(directory, 'large.png'), MAX_SITE_IMAGE_BYTES + 1)
execFileSync('mkfifo', [path.join(directory, 'pipe.png')])
const serve = createSiteImageHandler(configuration({ favicon: filename }))
for (const method of ['GET', 'HEAD'])
await expectNotFound(await serve('favicon', request(method)), method)
}
)
test('accepts exactly the size limit and caches unavailable selections until restart', async () => {
const bytes = Buffer.alloc(MAX_SITE_IMAGE_BYTES, 42)
await writeFile(path.join(directory, 'limit.png'), bytes)
const response = await createSiteImageHandler(configuration({ opengraph: 'limit.png' }))(
'opengraph',
request()
)
expect(Buffer.from(await response.arrayBuffer()).equals(bytes)).toBe(true)
const config = configuration({ favicon: 'late.png' })
const serve = createSiteImageHandler(config)
await expectNotFound(await serve('favicon', request()))
await writeFile(path.join(directory, 'late.png'), 'late')
await expectNotFound(await serve('favicon', request()))
expect(await (await createSiteImageHandler(config)('favicon', request())).text()).toBe('late')
})
test('requires a supplied config path for relative selections', async () => {
const config = configuration({ favicon: 'icon.png' })
delete config.paths.config
await expectNotFound(await createSiteImageHandler(config)('favicon', request()))
})
})
describe('public site-image URL selection', () => {
test.each([
undefined,
{},
siteImageDefaults,
{ favicon: siteImageDefaults.favicon },
{ opengraph: siteImageDefaults.opengraph }
])('keeps schema defaults on bundled URLs for input %j', (images) => {
for (const appAssets of ['', '/cdn/', 'https://cdn.test/assets/']) {
const config = configuration(images)
expect(config.content.images).toEqual(siteImageDefaults)
config.url.app = 'https://public.test'
config.url.app_assets = appAssets
const urls = siteImageUrls(config, new URL('http://internal.test/pieces/book'))
const prefix = appAssets.replace(/\/+$/, '')
expect(urls.favicon).toBe(`${prefix}/images/favicon.png`)
expect(urls.opengraph).toBe(new URL(`${prefix}/images/opengraph.png`, config.url.app).href)
}
})
test.each(['', '/garden', './garden', '//relative.example/garden'])(
'uses request origin for relative app URL %s and never exposes source paths',
(appUrl) => {
const config = configuration({ favicon: '/private/icon.png', opengraph: '/private/card.png' })
config.url.app = appUrl
config.url.app_assets = 'https://cdn.test'
expect(siteImageUrls(config, new URL('https://public.test/pieces/book'))).toEqual({
favicon: '/site-assets/favicon',
opengraph: 'https://public.test/site-assets/opengraph'
})
}
)
test.each(['favicon', 'opengraph'] as const)(
'selects %s independently of the other image',
(role) => {
for (const images of [
{ [role]: '/private/selected.png' },
{ ...siteImageDefaults, [role]: '/private/selected.png' }
]) {
const config = configuration(images)
expect(config.content.images).toEqual({
...siteImageDefaults,
[role]: '/private/selected.png'
})
config.url.app = 'https://public.test'
config.url.app_assets = '/cdn'
const urls = siteImageUrls(config, new URL('http://internal.test'))
expect(urls.favicon).toBe(
role === 'favicon' ? '/site-assets/favicon' : '/cdn/images/favicon.png'
)
expect(urls.opengraph).toBe(
`https://public.test/${role === 'opengraph' ? 'site-assets/opengraph' : 'cdn/images/opengraph.png'}`
)
}
}
)
})
+143
View File
@@ -0,0 +1,143 @@
import { createHash } from 'node:crypto'
import { constants } from 'node:fs'
import { open } from 'node:fs/promises'
import path from 'node:path'
import { siteImageDefaults, type Config } from '@luzzle/web.config'
import { absoluteSiteImage, type SiteImage, type SiteImageUrls } from '#lib/site-images.js'
export const MAX_SITE_IMAGE_BYTES = 5 * 1024 * 1024
const imageTypes: Record<SiteImage, Readonly<Record<string, string>>> = {
favicon: { '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' },
opengraph: {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif'
}
}
type ImageConfig = Pick<Config, 'content' | 'paths'>
interface ImageSnapshot {
bytes: Uint8Array<ArrayBuffer>
contentType: string
etag: string
}
export function siteImageUrls(
config: Pick<Config, 'content' | 'url'>,
requestUrl: URL
): SiteImageUrls {
const prefix = config.url.app_assets.replace(/\/+$/, '')
function imageUrl(image: SiteImage): string {
const source = config.content.images[image]
return source === siteImageDefaults[image] ? `${prefix}${source}` : `/site-assets/${image}`
}
return {
favicon: imageUrl('favicon'),
opengraph: absoluteSiteImage(imageUrl('opengraph'), config.url.app, requestUrl)
}
}
async function readImage(
filename: string,
contentType: string
): Promise<ImageSnapshot | undefined> {
const file = await open(filename, constants.O_RDONLY | constants.O_NONBLOCK)
try {
const before = await file.stat({ bigint: true })
if (!before.isFile() || before.size <= 0n || before.size > BigInt(MAX_SITE_IMAGE_BYTES)) return
// One extra byte detects growth without allowing an unbounded read.
const buffer = Buffer.allocUnsafe(Number(before.size) + 1)
let length = 0
while (length < buffer.length) {
const { bytesRead } = await file.read(buffer, length, buffer.length - length, length)
if (bytesRead === 0) break
length += bytesRead
}
const after = await file.stat({ bigint: true })
if (
length !== Number(before.size) ||
after.size !== before.size ||
after.mtimeNs !== before.mtimeNs ||
after.ctimeNs !== before.ctimeNs
)
return
const bytes = new Uint8Array(buffer.subarray(0, length))
return {
bytes,
contentType,
etag: `"${createHash('sha256').update(bytes).digest('base64url')}"`
}
} finally {
await file.close()
}
}
async function loadImage(
config: ImageConfig,
image: SiteImage
): Promise<ImageSnapshot | undefined> {
const source = config.content.images[image]
if (source === siteImageDefaults[image]) return
const contentType = imageTypes[image][path.extname(source).toLowerCase()]
if (!contentType) return
if (!path.isAbsolute(source) && !config.paths.config) return
const filename = path.resolve(path.dirname(config.paths.config ?? ''), source)
try {
return await readImage(filename, contentType)
} catch {
// The configured source and filesystem errors must never reach a public response.
return undefined
}
}
function matchesEtag(header: string | null, etag: string): boolean {
if (!header) return false
return (
header.trim() === '*' ||
header.split(',').some((tag) => tag.trim().replace(/^W\//, '') === etag)
)
}
function notFound(method: string): Response {
return new Response(method === 'HEAD' ? null : 'Not found', {
status: 404,
headers: {
'content-type': 'text/plain; charset=utf-8',
'content-length': '9',
'cache-control': 'no-store',
'x-content-type-options': 'nosniff'
}
})
}
/** Selected bytes (including failed reads) are restart-bound, just like configuration. */
export function createSiteImageHandler(config: ImageConfig) {
const snapshots = new Map<SiteImage, Promise<ImageSnapshot | undefined>>()
return async (image: string, request: Request): Promise<Response> => {
if (image !== 'favicon' && image !== 'opengraph') return notFound(request.method)
let pending = snapshots.get(image)
if (!pending) {
pending = loadImage(config, image)
snapshots.set(image, pending)
}
const snapshot = await pending
if (!snapshot) return notFound(request.method)
const headers = new Headers({
'content-type': snapshot.contentType,
'cache-control': 'public, max-age=0, must-revalidate',
'x-content-type-options': 'nosniff',
etag: snapshot.etag
})
if (matchesEtag(request.headers.get('if-none-match'), snapshot.etag)) {
return new Response(null, { status: 304, headers })
}
headers.set('content-length', String(snapshot.bytes.byteLength))
return new Response(request.method === 'HEAD' ? null : snapshot.bytes, { headers })
}
}
+13
View File
@@ -0,0 +1,13 @@
import { resolveAppOrigin } from '../../scripts/runtime-environment.js'
export type SiteImage = 'favicon' | 'opengraph'
export type SiteImageUrls = Record<SiteImage, string>
export function absoluteSiteImage(
image: string,
appUrl: string,
requestUrl: Pick<URL, 'href'>
): string {
const base = resolveAppOrigin(appUrl) ? appUrl : new URL('/', requestUrl.href)
return new URL(image, base).href
}
@@ -1,7 +1,7 @@
import { expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import type { ComponentProps } from 'svelte'
import { makeConfig } from '#lib/server/config.fixture.js'
import { makePublicConfig } from '#lib/server/config.fixture.js'
import Page from './+page.svelte'
const { resolve } = vi.hoisted(() => ({
@@ -17,7 +17,7 @@ vi.mock('$app/paths', () => ({ resolve }))
test('directory links encode segments once and retain nested separators and trailing slash', () => {
const props = {
data: {
config: makeConfig(),
config: makePublicConfig(),
meta: { title: 'Directory' },
pieceTypes: ['books'],
directory: { parent: '棚 #?%', current: '棚 #?%/literal%2F' },
@@ -3,6 +3,7 @@ import { render } from 'svelte/server'
import type { ComponentProps } from 'svelte'
import type { WebPieces } from '@luzzle/web.db'
import { resolve } from '$app/paths'
import { makePublicConfig } from '#lib/server/config.fixture.js'
import Page from './+page.svelte'
vi.mock('$app/paths', () => ({
@@ -33,14 +34,15 @@ beforeEach(() => {
afterEach(() => vi.useRealTimers())
function html(pieces = [piece]) {
const config = makePublicConfig({
url: { app: 'http://localhost', app_assets: '', luzzle_assets: '' }
})
config.content.text = { title: 'Garden', description: '' }
return render(Page, {
props: {
data: {
pieceTypes: ['books'],
config: {
content: { text: { title: 'Garden', description: '' } },
url: { app: 'http://localhost', app_assets: '', luzzle_assets: '' }
},
config,
meta: { title: 'Dashboard' },
pieceStats: { total: pieces.length, byType: [] },
fileCount: pieces.length,
@@ -9,7 +9,7 @@
</script>
<svelte:head>
<link rel="icon" href="{data.config.url.app_assets}/images/favicon.png" />
<link rel="icon" href={data.config.content.images.favicon} />
</svelte:head>
{#key params.jobId}
@@ -3,7 +3,7 @@ import { beforeEach, afterEach, expect, test, vi } from 'vitest'
import { createRawSnippet, type ComponentProps } from 'svelte'
import { render } from 'svelte/server'
import { parse } from 'svelte/compiler'
import { makeConfig } from '#lib/server/config.fixture.js'
import { makePublicConfig } from '#lib/server/config.fixture.js'
import { publishedPiece as rawPublishedPiece } from '#lib/pieces/viewer/fixtures/published.js'
import PreviewLayout from './+layout.svelte'
import PreviewResetLayout from '../+layout@.svelte'
@@ -48,7 +48,7 @@ type Status = 'waiting' | 'running' | 'completed' | 'failed' | 'expired'
function data(status: Status): ComponentProps<typeof PreviewLayout>['data'] {
const common = {
pieceTypes: ['books'],
config: makeConfig(),
config: makePublicConfig(),
meta: { title: 'Garden', description: 'Description' },
preview: true as const,
file,
@@ -98,11 +98,19 @@ beforeEach(() => {
afterEach(() => vi.unstubAllGlobals())
test.each(['/static', 'https://cdn.test/site-assets', ''])(
'Preview reset layout adds only the configured site favicon for base %j in every job state',
'Preview reset layout renders the resolved default favicon for original asset base %j in every job state',
(base) => {
for (const status of ['waiting', 'running', 'completed', 'failed', 'expired'] as const) {
const props = data(status)
props.config.url.app_assets = base
props.config = makePublicConfig({
url: { ...props.config.url, app: 'https://public.test/garden', app_assets: base }
})
expect(props.config.content.images).toEqual({
favicon: `${base}/images/favicon.png`,
opengraph: new URL(`${base}/images/opengraph.png`, 'https://public.test').href
})
// Defaults are already in the payload, not recalculated by the consumer.
props.config.url.app_assets = 'https://unrelated-cdn.test/assets'
const result = render(PreviewResetLayout, {
props: { data: props, params: { path: file }, children }
})
@@ -119,6 +127,30 @@ test.each(['/static', 'https://cdn.test/site-assets', ''])(
}
)
test.each([false, true])(
'Preview reset layout consumes resolved favicon selection %s in every state',
(selected) => {
for (const status of ['waiting', 'running', 'completed', 'failed', 'expired'] as const) {
const props = data(status)
props.config.url.app_assets = 'https://unrelated-cdn.test/assets'
props.config.url.app = 'https://public.test'
const favicon = selected ? '/site-assets/favicon' : '/static/images/favicon.png'
const opengraph = selected
? 'https://public.test/site-assets/opengraph'
: 'https://public.test/static/images/opengraph.png'
props.config.content.images = { favicon, opengraph }
const result = render(PreviewResetLayout, {
props: { data: props, params: { path: file }, children }
})
expect(result.head).toContain(`rel="icon" href="${props.config.content.images.favicon}"`)
expect(result.head.match(/rel="icon"/g)).toHaveLength(1)
expect(result.head).not.toContain('property="og:')
expect(result.head).not.toContain('site-assets/opengraph')
expect(result.body).toContain('Preview child fixture')
}
}
)
// Structural guard for the remount boundary; SSR cannot exercise client replacement or SSE cleanup.
test('Preview keys the entire job layout subtree by job, not the current tab', () => {
const source = readFileSync(new URL('../+layout@.svelte', import.meta.url), 'utf8')
@@ -1,7 +1,7 @@
import { beforeEach, expect, test, vi } from 'vitest'
import { createRawSnippet, type ComponentProps } from 'svelte'
import { render } from 'svelte/server'
import { makeConfig } from '#lib/server/config.fixture.js'
import { makePublicConfig } from '#lib/server/config.fixture.js'
import {
publishedPiece as rawPublishedPiece,
asset
@@ -57,7 +57,7 @@ type Status = 'waiting' | 'running' | 'completed' | 'failed' | 'expired'
function data(status: Status = 'completed'): Data {
const common = {
pieceTypes: ['books'],
config: makeConfig({
config: makePublicConfig({
url: {
app: 'https://public.test/site',
app_assets: '/static',
@@ -1,7 +1,7 @@
import { beforeEach, expect, test, vi } from 'vitest'
import { createRawSnippet, type ComponentProps } from 'svelte'
import { render } from 'svelte/server'
import { makeConfig } from '#lib/server/config.fixture.js'
import { makePublicConfig } from '#lib/server/config.fixture.js'
import {
publishedPiece as rawPublishedPiece,
asset
@@ -79,7 +79,7 @@ type Status = 'waiting' | 'running' | 'completed' | 'failed' | 'expired'
function data(status: Status = 'completed', png = true): Data {
const common = {
pieceTypes: ['books'],
config: makeConfig({
config: makePublicConfig({
url: {
app: 'https://public.test/site',
app_assets: '/static',
@@ -11,7 +11,7 @@
<svelte:head>
<title>Published: {data.piece.title}</title>
<meta name="robots" content="noindex" />
<link rel="icon" href="{data.config.url.app_assets}/images/favicon.png" />
<link rel="icon" href={data.config.content.images.favicon} />
</svelte:head>
<ViewerHeader file={data.file} />
@@ -4,6 +4,7 @@ import { render } from 'svelte/server'
import type { ComponentProps } from 'svelte'
import { MarkdownPublicationStatus } from '#lib/pieces/types.js'
import { editorHref, publishedHref } from '#lib/pieces/navigation.js'
import { makePublicConfig } from '#lib/server/config.fixture.js'
import {
publishedPiece as rawPublishedPiece,
asset
@@ -52,6 +53,10 @@ function data(
status: MarkdownPublicationStatus = MarkdownPublicationStatus.MATCHING,
time: number | null = 1234
): ComponentProps<typeof Overview>['data'] {
const config = makePublicConfig({
url: { app: '/garden', app_assets: '/static', luzzle_assets: '/cdn' }
})
config.content.text = { title: 'Garden', description: 'Description' }
return {
pieceTypes: ['books'],
file: 'books/<draft #?%>.books.md',
@@ -59,10 +64,7 @@ function data(
tags: [{ slug: 'fiction', tag: 'Fiction' }],
publication: { lastPublishedAt: time, markdownStatus: status },
publishedUrl: 'https://public.test/garden/pieces/books/published%20%23%3F%25',
config: {
content: { text: { title: 'Garden', description: 'Description' } },
url: { app: '/garden', app_assets: '/static', luzzle_assets: '/cdn' }
},
config,
meta: { title: 'Garden', description: 'Description' }
}
}
@@ -503,10 +505,18 @@ test('Overview renders one table per family with every image and Markdown output
})
test.each(['/static', 'https://cdn.test/site-assets', ''])(
'Published reset layout adds only the configured site favicon for asset base %j',
'Published reset layout renders the resolved default favicon for original asset base %j',
(base) => {
const props = data()
props.config.url.app_assets = base
props.config = makePublicConfig({
url: { ...props.config.url, app: 'https://public.test/garden', app_assets: base }
})
expect(props.config.content.images).toEqual({
favicon: `${base}/images/favicon.png`,
opengraph: new URL(`${base}/images/opengraph.png`, 'https://public.test').href
})
// Defaults are already in the payload, not recalculated by the consumer.
props.config.url.app_assets = 'https://unrelated-cdn.test/assets'
const result = render(PublishedTree, { props: { data: props } })
expect(result.head).toContain(`rel="icon" href="${base}/images/favicon.png"`)
expect(result.head.match(/rel="icon"/g)).toHaveLength(1)
@@ -519,6 +529,26 @@ test.each(['/static', 'https://cdn.test/site-assets', ''])(
}
)
test.each([false, true])(
'Published reset layout consumes resolved favicon selection %s',
(selected) => {
const props = data()
props.config.url.app_assets = 'https://unrelated-cdn.test/assets'
props.config.url.app = 'https://public.test'
const favicon = selected ? '/site-assets/favicon' : '/static/images/favicon.png'
const opengraph = selected
? 'https://public.test/site-assets/opengraph'
: 'https://public.test/static/images/opengraph.png'
props.config.content.images = { favicon, opengraph }
const result = render(PublishedTree, { props: { data: props, view: 'share' } })
expect(result.head).toContain(`rel="icon" href="${props.config.content.images.favicon}"`)
expect(result.head.match(/rel="icon"/g)).toHaveLength(1)
expect(result.head).not.toContain('property="og:')
expect(result.head).not.toContain('site-assets/opengraph')
expect(result.body).toContain('data-renderer="composition"')
}
)
test('reset layout keeps its inspector title/noindex and adds only the favicon, not public SEO', () => {
const result = render(PublishedTree, { props: { data: data() } })
expect(result.head).toContain('Published: Published &lt;Book>')
@@ -1,6 +1,7 @@
import { expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import type { ComponentProps } from 'svelte'
import { makePublicConfig } from '#lib/server/config.fixture.js'
import Page from './+page.svelte'
import type { RunView } from './+page.server.js'
@@ -57,13 +58,14 @@ function html({
diff: { schemas, pieces }
}
: null
const config = makePublicConfig({
url: { app: 'http://localhost', app_assets: '', luzzle_assets: '' }
})
config.content.text = { title: 'Test', description: '' }
const data = {
pieceTypes: ['books'],
meta: { title: 'Publish' },
config: {
url: { app: 'http://localhost', app_assets: '', luzzle_assets: '' },
content: { text: { title: 'Test', description: '' } }
},
config,
audit,
publish: publication
}
+17 -14
View File
@@ -1,24 +1,27 @@
import { config, type AppConfigPublic } from '#lib/server/config.js'
import { siteImageUrls } from '#lib/server/site-images.js'
import { getConfiguredPieceTypes } from '#lib/pieces/types.server.js'
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals }) => {
export const load: LayoutServerLoad = async ({ locals, url }) => {
locals.inlineConfiguredStyles()
const publicConfig: AppConfigPublic = {
content: {
text: {
title: config.content.text.title,
description: config.content.text.description
},
images: siteImageUrls(config, url)
},
url: {
app: config.url.app,
app_assets: config.url.app_assets,
luzzle_assets: config.url.luzzle_assets
}
}
return {
pieceTypes: getConfiguredPieceTypes(),
config: {
content: {
text: {
title: config.content.text.title,
description: config.content.text.description
}
},
url: {
app: config.url.app,
app_assets: config.url.app_assets,
luzzle_assets: config.url.luzzle_assets
}
} satisfies AppConfigPublic,
config: publicConfig,
meta: {
title: config.content.text.title,
description: config.content.text.description
+86 -8
View File
@@ -1,11 +1,14 @@
import { expect, test, vi } from 'vitest'
import { beforeEach, expect, test, vi } from 'vitest'
import { siteImageDefaults, validateConfig } from '@luzzle/web.config'
import { load } from './+layout.server'
import { config } from '#lib/server/config.js'
vi.mock('#lib/server/config.js', async () => {
const { makeConfig: baseConfig, oidcAuth } = await import('#lib/server/config.fixture.js')
const { makeConfig, oidcAuth } = await import('#lib/server/config.fixture.js')
const base = makeConfig()
return {
config: {
...baseConfig(),
...base,
auth: oidcAuth,
ai: { provider: 'google', api_key: 'private-ai-key' },
pieces: [
@@ -20,24 +23,99 @@ vi.mock('#lib/server/config.js', async () => {
}
],
content: {
text: { title: 'Public title', description: 'Public description' }
text: { title: 'Public title', description: 'Public description' },
images: base.content.images
},
url: { app: 'https://example.test', app_assets: '/app', luzzle_assets: '/assets' }
}
}
})
beforeEach(() => {
config.content.images = validateConfig({}, {}).content.images
config.url.app = 'https://example.test'
config.url.app_assets = '/app'
})
function event(inlineConfiguredStyles = vi.fn()) {
return {
locals: { inlineConfiguredStyles },
url: new URL('https://request.test/garden')
} as unknown as Parameters<typeof load>[0]
}
test('the layout returns only its safe projection and keeps configured CSS out of page data', async () => {
const inlineConfiguredStyles = vi.fn()
expect(
await load({ locals: { inlineConfiguredStyles } } as unknown as Parameters<typeof load>[0])
).toEqual({
expect(await load(event(inlineConfiguredStyles))).toEqual({
pieceTypes: ['runtime-kind'],
config: {
content: { text: { title: 'Public title', description: 'Public description' } },
content: {
text: { title: 'Public title', description: 'Public description' },
images: {
favicon: '/app/images/favicon.png',
opengraph: 'https://example.test/app/images/opengraph.png'
}
},
url: { app: 'https://example.test', app_assets: '/app', luzzle_assets: '/assets' }
},
meta: { title: 'Public title', description: 'Public description' }
})
expect(inlineConfiguredStyles).toHaveBeenCalledExactlyOnceWith()
})
test.each([
[undefined, '/app/images/favicon.png', 'https://example.test/app/images/opengraph.png'],
[{}, '/app/images/favicon.png', 'https://example.test/app/images/opengraph.png'],
[siteImageDefaults, '/app/images/favicon.png', 'https://example.test/app/images/opengraph.png'],
[
{ favicon: siteImageDefaults.favicon },
'/app/images/favicon.png',
'https://example.test/app/images/opengraph.png'
],
[
{ opengraph: siteImageDefaults.opengraph },
'/app/images/favicon.png',
'https://example.test/app/images/opengraph.png'
],
[
{ favicon: '/private/content/secret-icon.svg' },
'/site-assets/favicon',
'https://example.test/app/images/opengraph.png'
],
[
{ opengraph: '/private/content/secret-card.webp' },
'/app/images/favicon.png',
'https://example.test/site-assets/opengraph'
],
[
{ favicon: '/private/content/secret-icon.svg', opengraph: '/private/content/secret-card.webp' },
'/site-assets/favicon',
'https://example.test/site-assets/opengraph'
]
] as const)(
'projects only resolved public image URLs for %j',
async (images, favicon, opengraph) => {
config.content.images = validateConfig(images ? { content: { images } } : {}, {}).content.images
const result = await load(event())
expect(result).toMatchObject({
config: { content: { images: { favicon, opengraph } } }
})
expect(JSON.stringify(result)).not.toMatch(
/private|secret-|private-ai-key|clientSecret|templates/
)
}
)
test.each(['', '/garden', '//relative.example/garden'])(
'uses the request origin with relative app URL %j',
async (app) => {
config.url.app = app
config.content.images = validateConfig(
{ content: { images: { opengraph: '/private/card.png' } } },
{}
).content.images
expect(await load(event())).toMatchObject({
config: { content: { images: { opengraph: 'https://request.test/site-assets/opengraph' } } }
})
}
)
@@ -1,6 +1,7 @@
import type { PublicWebPiece } from '@luzzle/web.pieces'
import type { RequestHandler } from './$types'
import { config } from '#lib/server/config.js'
import { siteImageUrls } from '#lib/server/site-images.js'
import { generateRssFeed, generateJsonFeed, type RssFeed, type JsonFeed } from 'feedsmith'
import { getPiecesForFeed } from '#lib/feeds/utils.js'
import { error } from '@sveltejs/kit'
@@ -13,7 +14,7 @@ type FeedItem<T extends FeedRss | FeedJson> = Exclude<T['items'], undefined>[num
type FeedRssItem = FeedItem<FeedRss>
type FeedJsonItem = FeedItem<FeedJson>
function getRssFeedFromPieces(pieces: PublicWebPiece[], folder?: string) {
function getRssFeedFromPieces(pieces: PublicWebPiece[], image: string, folder?: string) {
const feed: FeedRss = {
title: folder ? config.content.text.title : `${config.content.text.title} | ${folder}`,
description: config.content.text.description,
@@ -29,10 +30,10 @@ function getRssFeedFromPieces(pieces: PublicWebPiece[], folder?: string) {
link: `${config.url.app}/search`
},
image: {
url: `${config.url.app_assets}/images/opengraph.png`,
url: image,
description: config.content.text.title,
title: config.content.text.title,
link: `${config.url.app_assets}/images/opengraph.png`
link: config.url.app
},
items: pieces.map(
(piece): FeedRssItem => ({
@@ -113,7 +114,11 @@ export const GET: RequestHandler = async (event) => {
}
if (format === 'xml') {
const rss = getRssFeedFromPieces(pieces, type ? `pieces/${type}` : 'pieces')
const rss = getRssFeedFromPieces(
pieces,
siteImageUrls(config, event.url).opengraph,
type ? `pieces/${type}` : 'pieces'
)
const stylesheets = [{ type: 'text/css', href: '/css/feed.css' }]
const body = generateRssFeed(rss as RssFeed<Date>, { stylesheets })
return new Response(body, { headers: { 'content-type': 'application/xml' } })
@@ -1,23 +1,39 @@
import { beforeEach, expect, test, vi } from 'vitest'
import { parseRssFeed } from 'feedsmith'
import { siteImageDefaults, validateConfig } from '@luzzle/web.config'
import { config } from '#lib/server/config.js'
import { GET } from './+server'
const { config, getPiecesForFeed } = vi.hoisted(() => ({
config: {
pieces: [{ type: 'field-notes' }, { type: 'specimens' }],
content: { text: { title: 'Garden', description: 'Field observations' } },
url: { app: 'https://app.example', app_assets: 'https://assets.example' }
},
getPiecesForFeed: vi.fn()
}))
vi.mock('#lib/server/config.js', () => ({ config }))
const { getPiecesForFeed } = vi.hoisted(() => ({ getPiecesForFeed: vi.fn() }))
vi.mock('#lib/server/config.js', async () => {
const { makeConfig } = await import('#lib/server/config.fixture.js')
const base = makeConfig()
return {
config: {
...base,
pieces: [{ type: 'field-notes' }, { type: 'specimens' }],
content: {
...base.content,
text: { title: 'Garden', description: 'Field observations' }
},
url: { ...base.url, app: 'https://app.example', app_assets: 'https://assets.example' }
}
}
})
vi.mock('#lib/feeds/utils.js', () => ({ getPiecesForFeed }))
function event(type: string | undefined, format: string) {
return { params: { piece: type, format } } as Parameters<typeof GET>[0]
return {
params: { piece: type, format },
url: new URL('https://request.test/rss/pieces/feed.xml')
} as Parameters<typeof GET>[0]
}
beforeEach(() => {
vi.clearAllMocks()
config.content.images = validateConfig({}, {}).content.images
config.url.app = 'https://app.example'
config.url.app_assets = 'https://assets.example'
getPiecesForFeed.mockResolvedValue([
{
id: 'note',
@@ -57,3 +73,40 @@ test.each(formats)('%s rejects unknown types before collecting feed pieces', asy
await expect(GET(event('unconfigured', format))).rejects.toMatchObject({ status: 404 })
expect(getPiecesForFeed).not.toHaveBeenCalled()
})
test.each([
[undefined, 'https://assets.example/images/opengraph.png'],
[{}, 'https://assets.example/images/opengraph.png'],
[siteImageDefaults, 'https://assets.example/images/opengraph.png'],
[{ favicon: siteImageDefaults.favicon }, 'https://assets.example/images/opengraph.png'],
[{ opengraph: siteImageDefaults.opengraph }, 'https://assets.example/images/opengraph.png'],
[{ favicon: '/private/icon.svg' }, 'https://assets.example/images/opengraph.png'],
[{ opengraph: '/private/card.webp' }, 'https://app.example/site-assets/opengraph'],
[
{ favicon: '/private/icon.svg', opengraph: '/private/card.webp' },
'https://app.example/site-assets/opengraph'
]
] as const)('XML channel image resolves independently for %j', async (images, image) => {
config.content.images = validateConfig(images ? { content: { images } } : {}, {}).content.images
const xml = await (await GET(event(undefined, 'xml'))).text()
expect(parseRssFeed(xml).image).toMatchObject({ url: image, link: config.url.app })
expect(xml).toContain('https://app.example/pieces/field-notes/observation')
expect(xml).not.toMatch(/private|card.webp|icon.svg|site-assets\/favicon/)
})
test.each(['', '/garden', '//relative.example/garden'])(
'XML channel image uses request origin for app URL %j',
async (app) => {
config.url.app = app
config.url.app_assets = '/static'
const xml = await (await GET(event('field-notes', 'xml'))).text()
expect(xml).toContain('<url>https://request.test/static/images/opengraph.png</url>')
}
)
test('XML escapes channel text and the absolute default image URL', async () => {
config.url.app_assets = 'https://assets.example/a&b'
const xml = await (await GET(event(undefined, 'xml'))).text()
expect(parseRssFeed(xml).image?.url).toBe('https://assets.example/a&b/images/opengraph.png')
expect(xml).toContain('<![CDATA[https://assets.example/a&b/images/opengraph.png]]>')
})
@@ -1,6 +1,7 @@
import type { PublicWebPiece } from '@luzzle/web.pieces'
import type { RequestHandler } from './$types'
import { config } from '#lib/server/config.js'
import { siteImageUrls } from '#lib/server/site-images.js'
import { generateRssFeed, generateJsonFeed, type RssFeed, type JsonFeed } from 'feedsmith'
import { getPiecesForTagFeed } from '#lib/feeds/utils.js'
import YAML from 'yaml'
@@ -11,7 +12,7 @@ type FeedItem<T extends FeedRss | FeedJson> = Exclude<T['items'], undefined>[num
type FeedRssItem = FeedItem<FeedRss>
type FeedJsonItem = FeedItem<FeedJson>
function getRssFeedFromPieces(pieces: PublicWebPiece[], tag?: string) {
function getRssFeedFromPieces(pieces: PublicWebPiece[], image: string, tag?: string) {
const feedTitle = tag ? `${config.content.text.title} | tag: ${tag}` : config.content.text.title
const feed: FeedRss = {
title: feedTitle,
@@ -28,10 +29,10 @@ function getRssFeedFromPieces(pieces: PublicWebPiece[], tag?: string) {
link: `${config.url.app}/search`
},
image: {
url: `${config.url.app_assets}/images/opengraph.png`,
url: image,
description: config.content.text.title,
title: config.content.text.title,
link: `${config.url.app_assets}/images/opengraph.png`
link: config.url.app
},
items: pieces.map(
(piece): FeedRssItem => ({
@@ -110,7 +111,7 @@ export const GET: RequestHandler = async (event) => {
}
if (format === 'xml') {
const rss = getRssFeedFromPieces(pieces, tag)
const rss = getRssFeedFromPieces(pieces, siteImageUrls(config, event.url).opengraph, tag)
const stylesheets = [{ type: 'text/css', href: '/css/feed.css' }]
const body = generateRssFeed(rss as RssFeed<Date>, { stylesheets })
return new Response(body, { headers: { 'content-type': 'application/xml' } })
@@ -0,0 +1,103 @@
import { beforeEach, expect, test, vi } from 'vitest'
import { parseRssFeed } from 'feedsmith'
import { siteImageDefaults, validateConfig } from '@luzzle/web.config'
import { config } from '#lib/server/config.js'
import { GET } from './+server'
const { getPiecesForTagFeed } = vi.hoisted(() => ({ getPiecesForTagFeed: vi.fn() }))
vi.mock('#lib/server/config.js', async () => {
const { makeConfig } = await import('#lib/server/config.fixture.js')
const base = makeConfig()
return {
config: {
...base,
content: {
...base.content,
text: { title: 'Garden', description: 'Field observations' }
},
url: { ...base.url, app: 'https://app.example', app_assets: '/static' }
}
}
})
vi.mock('#lib/feeds/utils.js', () => ({ getPiecesForTagFeed }))
function event(format = 'xml', tag = 'field-notes') {
return {
params: { tag, format },
url: new URL('https://request.test/rss/tags/field-notes/feed.xml')
} as Parameters<typeof GET>[0]
}
beforeEach(() => {
vi.clearAllMocks()
config.content.images = validateConfig({}, {}).content.images
config.content.text.title = 'Garden'
config.url.app = 'https://app.example'
config.url.app_assets = '/static'
getPiecesForTagFeed.mockResolvedValue([
{
id: 'note',
type: 'books',
slug: 'observation',
title: 'Observation',
note: 'A field note',
date_added: 1700000000000
}
])
})
test.each([
['xml', 'application/xml'],
['json', 'application/json'],
['md', 'text/markdown']
])('%s retains tag feed content and type', async (format, contentType) => {
const response = await GET(event(format))
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe(contentType)
const body = await response.text()
expect(body).toContain('Observation')
expect(body).toContain('Garden | tag: field-notes')
expect(getPiecesForTagFeed).toHaveBeenCalledExactlyOnceWith('field-notes')
})
test.each([
[undefined, 'https://app.example/static/images/opengraph.png'],
[{}, 'https://app.example/static/images/opengraph.png'],
[siteImageDefaults, 'https://app.example/static/images/opengraph.png'],
[{ favicon: siteImageDefaults.favicon }, 'https://app.example/static/images/opengraph.png'],
[{ opengraph: siteImageDefaults.opengraph }, 'https://app.example/static/images/opengraph.png'],
[{ favicon: '/private/icon.svg' }, 'https://app.example/static/images/opengraph.png'],
[{ opengraph: '/private/card.webp' }, 'https://app.example/site-assets/opengraph'],
[
{ favicon: '/private/icon.svg', opengraph: '/private/card.webp' },
'https://app.example/site-assets/opengraph'
]
] as const)('XML channel image resolves independently for %j', async (images, image) => {
config.content.images = validateConfig(images ? { content: { images } } : {}, {}).content.images
const xml = await (await GET(event())).text()
expect(parseRssFeed(xml).image).toMatchObject({ url: image, link: config.url.app })
expect(xml).toContain('https://app.example/pieces/books/observation')
expect(xml).not.toMatch(/private|card.webp|icon.svg|site-assets\/favicon/)
})
test.each(['', '/garden', '//relative.example/garden'])(
'XML image uses request origin for app URL %j',
async (app) => {
config.url.app = app
config.content.images = validateConfig(
{ content: { images: { opengraph: '/private/card.png' } } },
{}
).content.images
const xml = await (await GET(event())).text()
expect(xml).toContain('<url>https://request.test/site-assets/opengraph</url>')
}
)
test('XML retains an absolute asset prefix and escapes text and image URLs', async () => {
config.url.app_assets = 'https://assets.example/a&b'
config.content.text.title = 'Garden & <Notes>'
const xml = await (await GET(event())).text()
expect(parseRssFeed(xml).image?.url).toBe('https://assets.example/a&b/images/opengraph.png')
expect(xml).toContain('<![CDATA[https://assets.example/a&b/images/opengraph.png]]>')
expect(parseRssFeed(xml).image?.title).toBe('Garden & <Notes>')
})
@@ -0,0 +1,8 @@
import { config } from '#lib/server/config.js'
import { createSiteImageHandler } from '#lib/server/site-images.js'
import type { RequestHandler } from './$types'
const imageResponse = createSiteImageHandler(config)
export const GET: RequestHandler = ({ params, request }) => imageResponse(params.image, request)
export const HEAD = GET
+223 -1
View File
@@ -193,6 +193,43 @@ async function pageHtml(pathname = piecePath) {
expect(response.status).toBe(200)
return response.text()
}
async function expectSiteImageResponse(
endpoint: string,
bytes: Buffer,
contentType: string,
previousEtag?: string
): Promise<string> {
const etag = `"${createHash('sha256').update(bytes).digest('base64url')}"`
for (const method of ['GET', 'HEAD']) {
const response = await request(endpoint, 'image/*', {
method,
headers: { 'accept-encoding': 'br, gzip', range: 'bytes=0-1' }
})
expect(response.status).toBe(200)
expect(response.headers.get('content-length')).toBe(String(bytes.length))
expect(response.headers.get('content-type')).toBe(contentType)
expect(response.headers.get('etag')).toBe(etag)
expect(response.headers.get('cache-control')).toBe('public, max-age=0, must-revalidate')
expect(response.headers.has('content-encoding')).toBe(false)
expect(Buffer.from(await response.arrayBuffer())).toEqual(
method === 'HEAD' ? Buffer.alloc(0) : bytes
)
}
const conditional = await request(endpoint, 'image/*', {
headers: { 'if-none-match': `W/${etag}` }
})
expect(conditional.status).toBe(304)
expect(await conditional.text()).toBe('')
if (previousEtag && previousEtag !== etag) {
const stale = await request(endpoint, 'image/*', {
headers: { 'if-none-match': previousEtag }
})
expect(stale.status).toBe(200)
expect(Buffer.from(await stale.arrayBuffer())).toEqual(bytes)
}
return etag
}
function linkTargets(html: string, pathname: string) {
const pageUrl = new URL(pathname, origin)
return Array.from(
@@ -442,6 +479,7 @@ async function machineFeedSnapshot(): Promise<MachineFeeds> {
'<lastBuildDate>REQUEST_TIME</lastBuildDate>'
)
.replace(/^lastBuildDate:.*$/m, 'lastBuildDate: REQUEST_TIME')
.replaceAll(origin, 'REQUEST_ORIGIN')
}
}
}
@@ -721,6 +759,188 @@ describe('stock pieces and configured site sections through production HTTP', ()
expect(await invalid.text()).toBe('invalid type')
})
test('selects runtime site images after a blank build without changing piece OG or framework assets', async () => {
const original = await readFile(configFilename)
const imageDirectory = path.join(directory, 'content/images')
await mkdir(imageDirectory, { recursive: true })
const iconA =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="16" height="16" fill="red"/></svg>'
const iconB = iconA.replace('red', 'tan') // Same size, different content.
const iconLarge = iconA.replace('red', '#012345')
const smallPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=',
'base64'
)
const largePng = await readFile(path.join(webRoot, 'static/images/favicon.png'))
const variants = [
{ images: {}, icon: iconA, png: smallPng },
{ images: { favicon: './content/images/PRIVATE_ICON.svg' }, icon: iconA, png: smallPng },
{ images: { opengraph: './content/images/PRIVATE_SHARE.png' }, icon: iconA, png: smallPng },
{
images: {
favicon: './content/images/PRIVATE_ICON.svg',
opengraph: './content/images/PRIVATE_SHARE.png'
},
icon: iconA,
png: largePng
},
{
images: {
favicon: './content/images/PRIVATE_ICON.svg',
opengraph: './content/images/PRIVATE_SHARE.png'
},
icon: iconB,
png: smallPng
},
{
images: {
favicon: './content/images/PRIVATE_ICON.svg',
opengraph: './content/images/PRIVATE_SHARE.png'
},
icon: iconLarge,
png: largePng
}
]
const previousEtags = new Map<string, string>()
const baselineHtml = await pageHtml()
const frameworkUrl = baselineHtml.match(
/(?:href|src)="([^" ]*\/_app\/immutable\/[^" ]+\.css)"/
)?.[1]
expect(frameworkUrl).toBeDefined()
const frameworkPath = new URL(frameworkUrl!, `${origin}${piecePath}`).pathname
const frameworkBefore = await request(frameworkPath, 'text/css')
const frameworkBytes = Buffer.from(await frameworkBefore.arrayBuffer())
try {
for (const variant of variants) {
await stopBuiltApp()
await writeFile(path.join(imageDirectory, 'PRIVATE_ICON.svg'), variant.icon)
await writeFile(path.join(imageDirectory, 'PRIVATE_SHARE.png'), variant.png)
await writeFile(path.join(imageDirectory, 'PRIVATE_ICON.svg.gz'), 'stale gzip')
await writeFile(path.join(imageDirectory, 'PRIVATE_SHARE.png.br'), 'stale brotli')
const config = JSON.parse(original.toString('utf8'))
config.content.images = variant.images
config.url = { app: 'https://public.images.test', app_assets: '/cdn' }
await writeFile(configFilename, JSON.stringify(config))
origin = await startBuiltApp()
const favicon =
'favicon' in variant.images ? '/site-assets/favicon' : '/cdn/images/favicon.png'
const opengraph =
'opengraph' in variant.images ? '/site-assets/opengraph' : '/cdn/images/opengraph.png'
const root = await pageHtml('/')
expect(htmlHead(root)).toContain(`rel="icon" href="${favicon}"`)
expect(htmlHead(root)).toContain(
`property="og:image" content="https://public.images.test${opengraph}"`
)
const piece = await pageHtml()
expect(htmlHead(piece)).toContain(`rel="icon" href="${favicon}"`)
expect(htmlHead(piece)).toContain(
'property="og:image" content="https://public.images.test/pieces/assets/cutover/share.png"'
)
const dataResponse = await request(
'/__data.json?x-sveltekit-invalidated=11',
'application/json'
)
expect(dataResponse.status).toBe(200)
const data = await dataResponse.text()
for (const body of [root, piece, data]) {
for (const privatePath of [
'PRIVATE_ICON',
'PRIVATE_SHARE',
imageDirectory,
configFilename
])
expect(body).not.toContain(privatePath)
}
for (const feed of [
'/rss/pieces/feed.xml',
'/rss/pieces/cutover-kind/feed.xml',
'/rss/tags/field-notes/feed.xml'
]) {
const response = await request(feed, 'application/xml')
expect(response.status).toBe(200)
const xml = await response.text()
expect(xml).toContain(`<url>https://public.images.test${opengraph}</url>`)
expect(xml).not.toContain('PRIVATE_')
}
for (const [role, selected, bytes, mime] of [
['favicon', 'favicon' in variant.images, Buffer.from(variant.icon), 'image/svg+xml'],
['opengraph', 'opengraph' in variant.images, variant.png, 'image/png']
] as const) {
const endpoint = `/site-assets/${role}?file=/private/unselected.png`
if (!selected) {
const response = await request(endpoint)
expect(response.status).toBe(404)
expect(response.headers.get('cache-control')).toBe('no-store')
continue
}
const etag = await expectSiteImageResponse(endpoint, bytes, mime, previousEtags.get(role))
previousEtags.set(role, etag)
}
const frameworkAfter = await request(frameworkPath, 'text/css')
expect(frameworkAfter.headers.get('etag')).toBe(frameworkBefore.headers.get('etag'))
expect(frameworkAfter.headers.get('cache-control')).toBe(
frameworkBefore.headers.get('cache-control')
)
expect(Buffer.from(await frameworkAfter.arrayBuffer())).toEqual(frameworkBytes)
expect(await buildHashes()).toEqual(originalBuild)
}
} finally {
await stopBuiltApp()
await writeFile(configFilename, original)
origin = await startBuiltApp()
}
}, 60000)
test('keeps selected images public with authentication enabled and errors generic', async () => {
const original = await readFile(configFilename)
const config = JSON.parse(original.toString('utf8'))
config.url.app = 'https://authenticated.images.test'
config.auth = {
secret: 'image-test-session-secret-at-least-32-characters',
credentials: { username: 'admin', password: 'private-password' }
}
config.content.images = {
favicon: './content/auth-icon.svg',
opengraph: './content/PRIVATE_MISSING.png'
}
const icon = '<svg xmlns="http://www.w3.org/2000/svg"/>'
await mkdir(path.join(directory, 'content'), { recursive: true })
await writeFile(path.join(directory, 'content/auth-icon.svg'), icon)
try {
await stopBuiltApp()
await writeFile(configFilename, JSON.stringify(config))
origin = await startBuiltApp()
for (const method of ['GET', 'HEAD']) {
const response = await request('/site-assets/favicon', 'image/*', { method })
expect(response.status).toBe(200)
expect(await response.text()).toBe(method === 'HEAD' ? '' : icon)
for (const pathname of ['/site-assets/opengraph', '/site-assets/unknown']) {
const missing = await request(pathname, 'image/*', { method })
expect(missing.status).toBe(404)
expect(missing.headers.get('cache-control')).toBe('no-store')
expect(await missing.text()).toBe(method === 'HEAD' ? '' : 'Not found')
}
}
const signIn = await pageHtml('/signin')
expect(htmlHead(signIn)).toContain('rel="icon" href="/site-assets/favicon"')
expect(signIn).not.toContain('PRIVATE_MISSING')
const admin = await request('/admin', 'text/html')
expect(admin.status).toBe(302)
expect(admin.headers.get('location')).toContain('/signin')
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
const response = await request('/site-assets/favicon', 'application/json', {
method,
headers: { origin: config.url.app }
})
expect(response.status).toBe(405)
}
} finally {
await stopBuiltApp()
await writeFile(configFilename, original)
origin = await startBuiltApp()
}
}, 30000)
test('adopts configured origins and CDN prefixes across restarts, ignoring ambient ORIGIN without moving framework assets', async () => {
const original = await readFile(configFilename)
const baseline = await request()
@@ -772,7 +992,9 @@ describe('stock pieces and configured site sections through production HTTP', ()
expect(stablePageHeaders(response)).toEqual(stablePageHeaders(baseline))
expectSameOriginFrameworkUrls(html, requestOrigin)
const head = htmlHead(html)
expect(head).toContain(`rel="icon" href="${app_assets}/images/favicon.png"`)
expect(head).toContain(
`rel="icon" href="${app_assets.replace(/\/+$/, '')}/images/favicon.png"`
)
const pieces = luzzle_assets.replace(/\/+$/, '')
expect(html).toContain(`src="${pieces}/pieces/assets/cutover/cover%20small.png"`)
expect(head).toContain(
+8
View File
@@ -15,6 +15,14 @@ content:
text:
title: My garden
description: A few things worth keeping.
# Optional local files, each a readable regular file <=5 MiB.
# Relative to /app/config.yaml: ./content/images means /app/content/images.
# Uses the existing read-only content mount.
# Schema defaults: /images/favicon.png and /images/opengraph.png (bundled files).
# images:
# favicon: ./content/images/favicon.png # PNG, SVG, ICO.
# opengraph: ./content/images/share.png # PNG, JPEG, WebP, GIF.
# Restart web after changing these settings or files. See config.md#site-images.
# Match this type and these frontmatter selectors to your own archive.
# For example: hello.notes.md, with title and date_published in its frontmatter.
+32 -1
View File
@@ -34,7 +34,8 @@ Framework JavaScript and CSS stay same-origin. Keep the sample's production
internal service names, not the checkout's `luzzle-dev-*` addresses.
Restart web and worker after runtime config edits, then refresh. No hot reload.
[Republish affected pieces](templates.md#apply-your-choices) after OG changes.
[Republish affected pieces](templates.md#apply-your-choices) after piece OG template
or font changes, not site-image changes.
## Sign-in
@@ -105,6 +106,36 @@ Omit `ai` to disable generation; otherwise supply `provider: google` and
is optional. `assets.salt` defaults to `${LUZZLE_ASSET_SALT:-}`; keep it stable
because changing it changes published asset keys and URLs.
## Site images
`content.images.favicon` defaults to `/images/favicon.png`, and
`content.images.opengraph` defaults to `/images/opengraph.png`. These values select
the bundled static images, including the `url.app_assets` prefix.
To override an image, set its value to a nonempty local filename, not a remote URL
or browser path. Relative filenames resolve against
the supplied config filename; absolute filenames are also allowed. With the
supplied `/app/config.yaml` alias, `./content/images/share.png` resolves to
`/app/content/images/share.png`, not `/app/config/content/images/share.png`.
Reuse the existing read-only `content` mount; no new directory mounts are needed.
Each image must be a readable regular file, at most 5 MiB. Supported formats:
- Favicon: PNG, SVG, ICO.
- Site Open Graph image: PNG, JPEG, WebP, GIF.
Custom files use public, same-origin `/site-assets/favicon` and
`/site-assets/opengraph` endpoints,
respectively, without `url.app_assets`. These serve only the selected files,
without generation or embedding. Unsupported filename types fail configuration
validation. Missing, empty, unreadable, or oversized files return 404, with no
bundled fallback. These endpoints also return 404 when the bundled image is selected.
Restart web after changing image settings or files; neither is live-reloaded.
Piece OG images and their generation are unaffected; piece-specific OG images
still take precedence. Piece pages, including previews, use the site favicon.
Custom images do not require an application build.
## Error pages
Customize three independent presentations under `pages.error`: `"404"` is selected
+10 -1
View File
@@ -29,6 +29,13 @@ Set `url.app` to your public URL. Prepare an [archive](../archive.md), configure
its [piece types](config.md#piece-types), and enable [sign-in](config.md#sign-in)
to edit and publish through `/admin`. Without auth, admin access is disabled.
For optional [site images](config.md#site-images), place files in
`content/images/` and configure `content.images.favicon` or `.opengraph`.
For example, `./content/images/share.png` resolves from the supplied
`/app/config.yaml` alias to `/app/content/images/share.png`. The existing
read-only `content` mount is sufficient; keep files readable by the web service.
Restart web after changing these files or settings; no application build is needed.
## Start and publish
```sh
@@ -65,7 +72,9 @@ docker compose restart luzzle-web luzzle-worker
After changing `.env` or mounts, use `docker compose up -d --force-recreate`.
To update, back up your archive and deployment data, change `LUZZLE_VERSION` if
pinned, then run `docker compose pull` and `docker compose up -d`.
[Republish affected pieces](templates.md#apply-your-choices) after OG changes.
[Republish affected pieces](templates.md#apply-your-choices) after piece OG template
or font changes. Site-image settings and files are restart-bound: restart
`luzzle-web` after changing either, without rebuilding or republishing pieces.
## Preview cleanup
+42 -2
View File
@@ -5,6 +5,7 @@ import {
getConfigValue,
setConfigValue,
ConfigError,
siteImageDefaults,
type Config,
type ConfigPublic,
} from "./index.js";
@@ -18,6 +19,29 @@ describe("index (package entry point)", () => {
expectTypeOf(validateConfig).returns.toEqualTypeOf<Config>();
});
test("exports the schema's built-in site image sources", () => {
expect(siteImageDefaults).toEqual({
favicon: "/images/favicon.png",
opengraph: "/images/opengraph.png",
});
expect(validateConfig({}, {}).content.images).toEqual(siteImageDefaults);
});
test("resolved Config requires both image sources", () => {
expectTypeOf<Config["content"]["images"]>().toEqualTypeOf<{
favicon: string;
opengraph: string;
}>();
// @ts-expect-error Resolved images cannot omit the Open Graph source.
const faviconOnly: Config["content"]["images"] = { favicon: "./icon.png" };
// @ts-expect-error Resolved images cannot omit the favicon source.
const opengraphOnly: Config["content"]["images"] = {
opengraph: "./share.png",
};
expect(faviconOnly).not.toHaveProperty("opengraph");
expect(opengraphOnly).not.toHaveProperty("favicon");
});
test("exports ConfigError with structured validation issues", () => {
try {
validateConfig({ storage: { root: "" } }, {});
@@ -75,17 +99,33 @@ describe("index (package entry point)", () => {
credentials: { username: "admin", password: "private-password" },
},
ai: { provider: "google", api_key: "private-key" },
content: {
images: {
favicon: "/private/icon.png",
opengraph: "/private/share.png",
},
},
},
{},
);
const publicConfig: ConfigPublic = {
url: config.url,
content: config.content,
content: {
text: config.content.text,
images: {
favicon: "/site-assets/favicon",
opengraph: "/site-assets/opengraph",
},
},
};
expect(publicConfig.url.app).toBe("http://localhost:8080");
expect(publicConfig.content.text.title).toBe("Luzzle Explorer");
expectTypeOf<keyof ConfigPublic>().toEqualTypeOf<"url" | "content">();
expectTypeOf<keyof ConfigPublic["content"]>().toEqualTypeOf<"text">();
expectTypeOf<keyof ConfigPublic["content"]>().toEqualTypeOf<
"text" | "images"
>();
expect(publicConfig.content.images.favicon).toBe("/site-assets/favicon");
expect(JSON.stringify(publicConfig)).not.toContain("/private/");
expectTypeOf<keyof ConfigPublic["url"]>().toEqualTypeOf<
"app" | "app_assets" | "luzzle_assets"
>();
+1 -1
View File
@@ -6,7 +6,7 @@ import {
setConfigValue,
} from "./lib/config/config.js";
export { ConfigError } from "./lib/config/errors.js";
export { ConfigSchema } from "./lib/config/schema.js";
export { ConfigSchema, siteImageDefaults } from "./lib/config/schema.js";
export { createInputSchema } from "./lib/config/input-schema.js";
export { createFieldMappingSchema } from "./lib/mapping/schema.js";
export type { ConfigSchemaOptions } from "./lib/config/config.js";
@@ -82,6 +82,20 @@ describe("loadConfig", () => {
expect(() => loadConfig(filename)).toThrow(ConfigError);
});
test("loads image selections without resolving or reading the selected files", () => {
vi.stubEnv("CONFIG_TEST_SITE_IMAGE", "/missing/site image.JPEG");
writeFileSync(
filename,
"content:\n images:\n favicon: './missing/site icon.SVG'\n opengraph: '${CONFIG_TEST_SITE_IMAGE}'\n",
);
const config = loadConfig(filename);
expect(config.content.images).toEqual({
favicon: "./missing/site icon.SVG",
opengraph: "/missing/site image.JPEG",
});
expect(config.paths.config).toBe(filename);
});
test("fails for an explicitly supplied missing file", () => {
expect(() => loadConfig(filename)).toThrow(
expect.objectContaining({ code: "ENOENT" }),
+5 -1
View File
@@ -20,7 +20,11 @@ export interface ConfigSchemaOptions<
export type ConfigPublic = {
url: Pick<Config["url"], "app" | "luzzle_assets" | "app_assets">;
content: Config["content"];
content: {
text: Config["content"]["text"];
/** Resolved public URLs, never the configured filesystem filenames. */
images: { favicon: string; opengraph: string };
};
};
export function validateConfig(raw: unknown, env?: NodeJS.ProcessEnv): Config;
@@ -0,0 +1,298 @@
import { expect, expectTypeOf, test } from "vitest";
import { Value } from "@sinclair/typebox/value";
import type { Config, ConfigPublic } from "./config.js";
import { validateConfig } from "./config.js";
import { ConfigError } from "./errors.js";
import { ConfigSchema, siteImageDefaults } from "./schema.js";
import { createInputSchema } from "./input-schema.js";
const roles = ["favicon", "opengraph"] as const;
const inputSchema = createInputSchema(ConfigSchema);
function rawImage(role: (typeof roles)[number], filename: unknown) {
return { content: { images: { [role]: filename } } };
}
const invalidFilenames = [
"",
" ",
"\t\n",
"image",
"image.txt",
"image.png.exe",
".png",
"./.png",
"/content/.png",
"image.png/",
"image.png ",
"https://example.com/image.png",
"HTTP://example.com/image.png",
"ftp://example.com/image.png",
"file:///tmp/image.png",
"file:/tmp/image.png",
"data:image/png;base64,image.png",
"javascript:image.png",
"custom+scheme:image.png",
"1invalid://example.com/image.png",
"invalid scheme://example.com/image.png",
" ://example.com/image.png",
"./https://example.com/image.png",
"//example.com/image.png",
" //example.com/image.png",
" https://example.com/image.png",
"image.png?version=1",
"image.png#fragment",
"./query?directory/image.png",
"./fragment#directory/image.png",
"image\u0000.png",
"image\u001f.png",
"image\u007f.png",
"image\u0085.png",
"image\u009f.png",
"image\t.png",
"image\r.png",
"image\n.png",
"image.png\n",
"image.png\r\n",
];
test.each([{}, { content: {} }, { content: { images: {} } }])(
"defaults site images to built-in static sources: %j",
(raw) => {
const original = structuredClone(raw);
expect(validateConfig(raw, {}).content.images).toEqual(siteImageDefaults);
expect(raw).toEqual(original);
},
);
test("image defaults are not shared between configurations", () => {
const config = validateConfig({}, {});
config.content.images.favicon = "./selected.png";
expect(validateConfig({}, {}).content.images).toEqual(siteImageDefaults);
expect(siteImageDefaults).toEqual({
favicon: "/images/favicon.png",
opengraph: "/images/opengraph.png",
});
});
test.each(roles)(
"allows %s independently without changing text defaults",
(role) => {
const raw = rawImage(role, "./missing images/selected.png");
const original = structuredClone(raw);
const config = validateConfig(raw, {});
expect(config.content.images).toEqual({
...siteImageDefaults,
[role]: "./missing images/selected.png",
});
expect(config.content.text).toEqual(validateConfig({}, {}).content.text);
expect(raw).toEqual(original);
},
);
test("image defaults originate in the required resolved schema", () => {
const schema = ConfigSchema.properties.content.properties.images;
expect(schema.default).toEqual({});
expect(schema.required).toEqual([...roles]);
for (const role of roles) {
expect(schema.properties[role].type).toBe("string");
expect(schema.properties[role].default).toBe(siteImageDefaults[role]);
expect(Value.Check(schema, { [role]: siteImageDefaults[role] })).toBe(
false,
);
}
expect(Value.Default(schema, {})).toEqual(siteImageDefaults);
expect(Value.Check(schema, {})).toBe(false);
expect(Value.Check(schema, siteImageDefaults)).toBe(true);
});
test.each(roles)("accepts the explicit built-in default for %s", (role) => {
const raw = rawImage(role, siteImageDefaults[role]);
const original = structuredClone(raw);
expect(Value.Check(inputSchema, raw)).toBe(true);
expect(validateConfig(raw, {}).content.images).toEqual(siteImageDefaults);
expect(raw).toEqual(original);
});
test("accepts both explicit built-in defaults", () => {
expect(
validateConfig({ content: { images: { ...siteImageDefaults } } }, {})
.content.images,
).toEqual(siteImageDefaults);
});
test("accepts both selections alongside custom site text", () => {
const content = {
text: { title: "My garden", description: "Notes" },
images: { favicon: "./icon.ico", opengraph: "/content/share.jpeg" },
};
expect(validateConfig({ content }, {}).content).toEqual(content);
});
for (const [role, extensions] of [
["favicon", ["png", "svg", "ico"]],
["opengraph", ["png", "jpg", "jpeg", "webp", "gif"]],
] as const) {
test.each(extensions)(
`accepts ${role} .%s case-insensitively`,
(extension) => {
for (const suffix of [
extension,
extension.toUpperCase(),
extension[0].toUpperCase() + extension.slice(1),
]) {
for (const filename of [
`image.${suffix}`,
`./content/site images/image.${suffix}`,
`../site images/image.${suffix}`,
`/app/content/site images/image.${suffix}`,
]) {
expect(Value.Check(inputSchema, rawImage(role, filename))).toBe(true);
expect(
validateConfig(rawImage(role, filename), {}).content.images,
).toEqual({
...siteImageDefaults,
[role]: filename,
});
}
}
},
);
}
test.each([
["favicon", "image.jpg"],
["favicon", "image.jpeg"],
["favicon", "image.webp"],
["favicon", "image.gif"],
["opengraph", "image.svg"],
["opengraph", "image.ico"],
] as const)("rejects unsupported %s type %s", (role, filename) => {
expect(() => validateConfig(rawImage(role, filename), {})).toThrow(
ConfigError,
);
expect(() =>
validateConfig(rawImage(role, "${SITE_IMAGE}"), { SITE_IMAGE: filename }),
).toThrow(ConfigError);
});
test.each(invalidFilenames)(
"rejects invalid local image filename %j",
(filename) => {
for (const role of roles) {
const raw = rawImage(role, filename);
expect(Value.Check(inputSchema, raw)).toBe(false);
expect(() => validateConfig(raw, {})).toThrow(`/content/images/${role}`);
}
},
);
test.each([null, false, 42, [], {}, { filename: "./image.png" }])(
"rejects non-string image filenames: %j",
(filename) => {
for (const role of roles) {
expect(() => validateConfig(rawImage(role, filename), {})).toThrow(
ConfigError,
);
}
},
);
test.each([null, false, "./image.png", [], { unknown: "./image.png" }])(
"rejects malformed image sections and unknown selections: %j",
(images) => {
expect(() => validateConfig({ content: { images } }, {})).toThrow(
ConfigError,
);
},
);
test.each(roles)(
"interpolates literal environment references for %s",
(role) => {
for (const [filename, env, expected] of [
[
"${SITE_IMAGE}",
{ SITE_IMAGE: "/content/site image.PNG" },
"/content/site image.PNG",
],
["${SITE_IMAGE:-./fallback.png}", {}, "./fallback.png"],
[
"${SITE_IMAGE}",
{ SITE_IMAGE: siteImageDefaults[role] },
siteImageDefaults[role],
],
[
"${IMAGE_DIR}/image.${EXT}",
{ IMAGE_DIR: "./site images", EXT: "png" },
"./site images/image.png",
],
["./${NAME}.png", { NAME: "site image" }, "./site image.png"],
["./price$$5.png", {}, "./price$5.png"],
["./$${LITERAL}.png", {}, "./${LITERAL}.png"],
] as const) {
const raw = rawImage(role, filename);
const original = structuredClone(raw);
expect(Value.Check(inputSchema, raw)).toBe(true);
expect(validateConfig(raw, env).content.images).toEqual({
...siteImageDefaults,
[role]: expected,
});
expect(raw).toEqual(original);
}
},
);
test.each(invalidFilenames)(
"rejects invalid expanded image filename %j",
(filename) => {
for (const role of roles) {
const raw = rawImage(role, "${SITE_IMAGE}");
expect(Value.Check(inputSchema, raw)).toBe(true);
expect(() => validateConfig(raw, { SITE_IMAGE: filename })).toThrow(
`/content/images/${role}`,
);
}
},
);
test.each(roles)("checks %s extensions after expansion", (role) => {
expect(() =>
validateConfig(rawImage(role, "./image.${EXT}"), { EXT: "txt" }),
).toThrow(ConfigError);
expect(() => validateConfig(rawImage(role, "${SITE_IMAGE:-}"), {})).toThrow(
ConfigError,
);
expect(() =>
validateConfig(
rawImage(role, "${SITE_IMAGE:-https://example.com/image.png}"),
{},
),
).toThrow(ConfigError);
expect(() =>
validateConfig(rawImage(role, "https://${HOST}/image.png"), {
HOST: "example.com",
}),
).toThrow(ConfigError);
expect(() => validateConfig(rawImage(role, "${SITE_IMAGE}"), {})).toThrow(
`/content/images/${role}: Environment variable "SITE_IMAGE" is missing.`,
);
});
test("public content requires both resolved image URLs", () => {
expectTypeOf<ConfigPublic["content"]>().toEqualTypeOf<{
text: Config["content"]["text"];
images: { favicon: string; opengraph: string };
}>();
const content: ConfigPublic["content"] = {
text: { title: "My garden", description: "Notes" },
images: {
favicon: "/site-assets/favicon",
opengraph: "/site-assets/opengraph",
},
};
expect(content.images.favicon).toBe("/site-assets/favicon");
// @ts-expect-error Consumers must receive defaults, not infer absent selections.
const incomplete: ConfigPublic["content"] = { text: content.text };
expect(incomplete).not.toHaveProperty("images");
});
@@ -18,6 +18,21 @@ describe("configuration input schema", () => {
expect(InputSchema.required).toBeUndefined();
});
test("allows independent image overrides only in the input contract", () => {
const images = ConfigSchema.properties.content.properties.images;
const inputImages = InputSchema.properties.content.properties.images;
for (const value of [
{},
{ favicon: "./icon.png" },
{ opengraph: "./share.png" },
]) {
expect(Value.Check(inputImages, value)).toBe(true);
expect(Value.Check(images, value)).toBe(false);
}
expect(images.required).toEqual(["favicon", "opengraph"]);
expect(inputImages.required).toBeUndefined();
});
test("keeps required fields inside piece arrays", () => {
expect(
Value.Check(InputSchema, {
@@ -113,6 +128,25 @@ describe("configuration input schema", () => {
expect(JSON.stringify(schema)).toBe(before);
});
test("relaxes string patterns only for environment references at input time", () => {
const schema = Type.String({ pattern: "^local$", minLength: 1 });
const input = createInputSchema(schema);
for (const value of [
"local",
"${VALUE}",
"${VALUE:-local}",
"./${VALUE}.png",
]) {
expect(Value.Check(input, value)).toBe(true);
}
for (const value of ["remote", "", "${}", "${VALUE", 42, null]) {
expect(Value.Check(input, value)).toBe(false);
}
expect(schema.pattern).toBe("^local$");
expect(Value.Check(schema, "${VALUE}")).toBe(false);
expect(Value.Check(schema, "local")).toBe(true);
});
test("preserves optional fields and produces independently editable copies", () => {
const schema = Type.Object({ value: Type.Optional(Type.String()) });
const input = createInputSchema(schema);
@@ -2,7 +2,7 @@ import { TypeGuard } from "@sinclair/typebox";
import type { TSchema } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
/** User input may omit defaulted fields; resolved configuration may not. */
/** User input may omit defaults and use environment references in patterned strings. */
export function createInputSchema(schema: TSchema): TSchema {
const input = Value.Clone(schema);
@@ -17,6 +17,10 @@ export function createInputSchema(schema: TSchema): TSchema {
relax(node.items);
} else if (TypeGuard.IsUnion(node)) {
node.anyOf.forEach(relax);
} else if (TypeGuard.IsString(node) && node.pattern !== undefined) {
// Interpolation happens after input validation. Only the input pattern
// admits references; the original pattern checks the expanded value.
node.pattern = `(?:${node.pattern})|\\$\\{[^}:]+(?::-[^}]*)?\\}`;
}
}
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { ConfigError } from "./errors.js";
import { ConfigSchema } from "./schema.js";
import { ConfigSchema, siteImageDefaults } from "./schema.js";
import { loadConfig, validateConfig } from "./config.js";
const schema = Type.Object(
@@ -80,8 +80,12 @@ test("site identity text remains configurable without source settings", () => {
const content = {
text: { title: "My garden", description: "Notes and records" },
};
expect(validateConfig({ content }, {}).content).toEqual(content);
expect(validateConfig({ content }, {}).content).toEqual({
...content,
images: siteImageDefaults,
});
expect(validateConfig({}, {}).content).toEqual({
images: siteImageDefaults,
text: {
title: "Luzzle Explorer",
description: "A Luzzle Explorer instance",
@@ -363,8 +363,53 @@ const PagesSchema = Type.Object(
{ default: {}, additionalProperties: false },
);
function siteImageSchema(
extensions: string[],
defaultSource: string,
description: string,
) {
const extensionPattern = extensions
.map((extension) =>
extension
.split("")
.map((letter) => `[${letter}${letter.toUpperCase()}]`)
.join(""),
)
.join("|");
const notUrl = String.raw`(?!\s*(?:[a-zA-Z][a-zA-Z0-9+.-]*:|//))(?!.*://)`;
const noControlsQueryOrFragment = String.raw`(?!.*[\u0000-\u001f\u007f-\u009f?#])`;
const filenameWithExtension = String.raw`.*[^/]\.(?:${extensionPattern})`;
return Type.String({
minLength: 1,
pattern: `^${notUrl}${noControlsQueryOrFragment}${filenameWithExtension}$`,
default: defaultSource,
description,
});
}
/** These exact sources identify built-in static assets, not local file selections. */
export const siteImageDefaults = {
favicon: "/images/favicon.png",
opengraph: "/images/opengraph.png",
} as const;
const ContentSchema = Type.Object(
{
images: Type.Object(
{
favicon: siteImageSchema(
["png", "svg", "ico"],
siteImageDefaults.favicon,
"Built-in static favicon by default; otherwise a local filename (PNG, SVG or ICO), absolute or relative to the supplied config file. Supports explicit environment references.",
),
opengraph: siteImageSchema(
["png", "jpg", "jpeg", "webp", "gif"],
siteImageDefaults.opengraph,
"Built-in static site Open Graph image by default; otherwise a local filename (PNG, JPEG, WebP or GIF), absolute or relative to the supplied config file. Supports explicit environment references.",
),
},
{ default: {}, additionalProperties: false },
),
text: Type.Object(
{
title: Type.String({
@@ -362,6 +362,27 @@
"additionalProperties": false,
"type": "object",
"properties": {
"images": {
"default": {},
"additionalProperties": false,
"type": "object",
"properties": {
"favicon": {
"minLength": 1,
"pattern": "(?:^(?!\\s*(?:[a-zA-Z][a-zA-Z0-9+.-]*:|//))(?!.*://)(?!.*[\\u0000-\\u001f\\u007f-\\u009f?#]).*[^/]\\.(?:[pP][nN][gG]|[sS][vV][gG]|[iI][cC][oO])$)|\\$\\{[^}:]+(?::-[^}]*)?\\}",
"default": "/images/favicon.png",
"description": "Built-in static favicon by default; otherwise a local filename (PNG, SVG or ICO), absolute or relative to the supplied config file. Supports explicit environment references.",
"type": "string"
},
"opengraph": {
"minLength": 1,
"pattern": "(?:^(?!\\s*(?:[a-zA-Z][a-zA-Z0-9+.-]*:|//))(?!.*://)(?!.*[\\u0000-\\u001f\\u007f-\\u009f?#]).*[^/]\\.(?:[pP][nN][gG]|[jJ][pP][gG]|[jJ][pP][eE][gG]|[wW][eE][bB][pP]|[gG][iI][fF])$)|\\$\\{[^}:]+(?::-[^}]*)?\\}",
"default": "/images/opengraph.png",
"description": "Built-in static site Open Graph image by default; otherwise a local filename (PNG, JPEG, WebP or GIF), absolute or relative to the supplied config file. Supports explicit environment references.",
"type": "string"
}
}
},
"text": {
"default": {},
"additionalProperties": false,
+21
View File
@@ -362,6 +362,27 @@
"additionalProperties": false,
"type": "object",
"properties": {
"images": {
"default": {},
"additionalProperties": false,
"type": "object",
"properties": {
"favicon": {
"minLength": 1,
"pattern": "(?:^(?!\\s*(?:[a-zA-Z][a-zA-Z0-9+.-]*:|//))(?!.*://)(?!.*[\\u0000-\\u001f\\u007f-\\u009f?#]).*[^/]\\.(?:[pP][nN][gG]|[sS][vV][gG]|[iI][cC][oO])$)|\\$\\{[^}:]+(?::-[^}]*)?\\}",
"default": "/images/favicon.png",
"description": "Built-in static favicon by default; otherwise a local filename (PNG, SVG or ICO), absolute or relative to the supplied config file. Supports explicit environment references.",
"type": "string"
},
"opengraph": {
"minLength": 1,
"pattern": "(?:^(?!\\s*(?:[a-zA-Z][a-zA-Z0-9+.-]*:|//))(?!.*://)(?!.*[\\u0000-\\u001f\\u007f-\\u009f?#]).*[^/]\\.(?:[pP][nN][gG]|[jJ][pP][gG]|[jJ][pP][eE][gG]|[wW][eE][bB][pP]|[gG][iI][fF])$)|\\$\\{[^}:]+(?::-[^}]*)?\\}",
"default": "/images/opengraph.png",
"description": "Built-in static site Open Graph image by default; otherwise a local filename (PNG, JPEG, WebP or GIF), absolute or relative to the supplied config file. Supports explicit environment references.",
"type": "string"
}
}
},
"text": {
"default": {},
"additionalProperties": false,