Configure site favicon and default Open Graph image #181
No files matched your search
@@ -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><Book> & Garden</title>')
|
||||
expect(head).toContain('content="A "quote" & detail"')
|
||||
expect(head).toContain('href="/site-assets/favicon?a=1&b="icon""')
|
||||
expect(head).toContain('content="https://public.test/site-assets/opengraph?a=1&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 }
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 <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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
>();
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user