From 64524a71819d2a19a65d96e05021946390d8c567 Mon Sep 17 00:00:00 2001 From: eleith Date: Mon, 2 Nov 2026 09:55:40 -0800 Subject: [PATCH] [web] configure site favicon and default Open Graph image Resolve bundled defaults and owner-selected image URLs on the server, serve selected runtime files independently of build output, and preserve piece-specific Open Graph images.\n\nRefs #180. --- .changeset/site-image-overrides.md | 5 + .../components/layout/simple/SeoHead.svelte | 10 +- .../components/layout/simple/SeoHead.test.ts | 114 +++++++ .../lib/content/components/ErrorPage.test.ts | 15 +- apps/web/src/lib/server/config.fixture.ts | 21 +- apps/web/src/lib/server/site-images.test.ts | 273 ++++++++++++++++ apps/web/src/lib/server/site-images.ts | 143 +++++++++ apps/web/src/lib/site-images.ts | 13 + .../directory/[...directory]/page.test.ts | 4 +- apps/web/src/routes/(auth)/admin/page.test.ts | 10 +- .../piece/[...path]/preview/+layout@.svelte | 2 +- .../[...path]/preview/[jobId]/layout.test.ts | 40 ++- .../[...path]/preview/[jobId]/page.test.ts | 4 +- .../preview/[jobId]/share/page.test.ts | 4 +- .../piece/[...path]/published/+layout@.svelte | 2 +- .../piece/[...path]/published/page.test.ts | 42 ++- .../routes/(auth)/admin/publish/page.test.ts | 10 +- apps/web/src/routes/+layout.server.ts | 31 +- apps/web/src/routes/layout.server.test.ts | 94 +++++- .../feed.[format=feedFormat]/+server.ts | 13 +- .../feed.[format=feedFormat]/server.test.ts | 73 ++++- .../[tag]/feed.[format=feedFormat]/+server.ts | 9 +- .../feed.[format=feedFormat]/server.test.ts | 103 ++++++ .../src/routes/site-assets/[image]/+server.ts | 8 + apps/web/test/templates/routes.test.ts | 224 ++++++++++++- docs/web/config.example.yaml | 8 + docs/web/config.md | 33 +- docs/web/deployment.md | 11 +- packages/web/config/src/index.test.ts | 44 ++- packages/web/config/src/index.ts | 2 +- .../web/config/src/lib/config/config.test.ts | 14 + packages/web/config/src/lib/config/config.ts | 6 +- .../web/config/src/lib/config/images.test.ts | 298 ++++++++++++++++++ .../src/lib/config/input-schema.test.ts | 34 ++ .../web/config/src/lib/config/input-schema.ts | 6 +- .../web/config/src/lib/config/schema.test.ts | 8 +- packages/web/config/src/lib/config/schema.ts | 45 +++ .../src/lib/config/web.config.schema.json | 21 ++ packages/web/templates/config.schema.json | 21 ++ 39 files changed, 1731 insertions(+), 87 deletions(-) create mode 100644 .changeset/site-image-overrides.md create mode 100644 apps/web/src/lib/components/layout/simple/SeoHead.test.ts create mode 100644 apps/web/src/lib/server/site-images.test.ts create mode 100644 apps/web/src/lib/server/site-images.ts create mode 100644 apps/web/src/lib/site-images.ts create mode 100644 apps/web/src/routes/rss/tags/[tag]/feed.[format=feedFormat]/server.test.ts create mode 100644 apps/web/src/routes/site-assets/[image]/+server.ts create mode 100644 packages/web/config/src/lib/config/images.test.ts diff --git a/.changeset/site-image-overrides.md b/.changeset/site-image-overrides.md new file mode 100644 index 00000000..0982ae2d --- /dev/null +++ b/.changeset/site-image-overrides.md @@ -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. diff --git a/apps/web/src/lib/components/layout/simple/SeoHead.svelte b/apps/web/src/lib/components/layout/simple/SeoHead.svelte index 2783284e..767accef 100644 --- a/apps/web/src/lib/components/layout/simple/SeoHead.svelte +++ b/apps/web/src/lib/components/layout/simple/SeoHead.svelte @@ -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) @@ -24,8 +24,8 @@ {/if} {#if meta.image} - {:else} - + {:else if images} + {/if} {#if meta.type} @@ -34,7 +34,9 @@ {/if} - + {#if images} + + {/if} {#if !browser} - + {#key params.jobId} diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/layout.test.ts b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/layout.test.ts index 0d735489..43911ba8 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/layout.test.ts +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/layout.test.ts @@ -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['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') diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/page.test.ts b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/page.test.ts index d2e51a44..7b5f3c69 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/page.test.ts +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/page.test.ts @@ -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', diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/share/page.test.ts b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/share/page.test.ts index 9b28784e..a2d3c0ce 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/share/page.test.ts +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/share/page.test.ts @@ -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', diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/+layout@.svelte b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/+layout@.svelte index e21d0b9e..6f452d07 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/+layout@.svelte +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/+layout@.svelte @@ -11,7 +11,7 @@ Published: {data.piece.title} - + diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/page.test.ts b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/page.test.ts index 0601b7c5..45029300 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/page.test.ts +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/page.test.ts @@ -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['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/.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>') diff --git a/apps/web/src/routes/(auth)/admin/publish/page.test.ts b/apps/web/src/routes/(auth)/admin/publish/page.test.ts index 244702df..ba5ec7b7 100644 --- a/apps/web/src/routes/(auth)/admin/publish/page.test.ts +++ b/apps/web/src/routes/(auth)/admin/publish/page.test.ts @@ -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 } diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts index 2493edca..ad6f6d38 100644 --- a/apps/web/src/routes/+layout.server.ts +++ b/apps/web/src/routes/+layout.server.ts @@ -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 diff --git a/apps/web/src/routes/layout.server.test.ts b/apps/web/src/routes/layout.server.test.ts index 6c56bae9..35200e3b 100644 --- a/apps/web/src/routes/layout.server.test.ts +++ b/apps/web/src/routes/layout.server.test.ts @@ -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[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[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' } } } + }) + } +) diff --git a/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/+server.ts b/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/+server.ts index 021ef24c..219dd035 100644 --- a/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/+server.ts +++ b/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/+server.ts @@ -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 = Exclude[num type FeedRssItem = FeedItem type FeedJsonItem = FeedItem -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, { stylesheets }) return new Response(body, { headers: { 'content-type': 'application/xml' } }) diff --git a/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/server.test.ts b/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/server.test.ts index 2847a918..e4e5d81c 100644 --- a/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/server.test.ts +++ b/apps/web/src/routes/rss/pieces/[[piece]]/feed.[format=feedFormat]/server.test.ts @@ -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[0] + return { + params: { piece: type, format }, + url: new URL('https://request.test/rss/pieces/feed.xml') + } as Parameters[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('https://request.test/static/images/opengraph.png') + } +) + +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('') +}) diff --git a/apps/web/src/routes/rss/tags/[tag]/feed.[format=feedFormat]/+server.ts b/apps/web/src/routes/rss/tags/[tag]/feed.[format=feedFormat]/+server.ts index 95985b66..6c6cff34 100644 --- a/apps/web/src/routes/rss/tags/[tag]/feed.[format=feedFormat]/+server.ts +++ b/apps/web/src/routes/rss/tags/[tag]/feed.[format=feedFormat]/+server.ts @@ -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 = Exclude[num type FeedRssItem = FeedItem type FeedJsonItem = FeedItem -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, { stylesheets }) return new Response(body, { headers: { 'content-type': 'application/xml' } }) diff --git a/apps/web/src/routes/rss/tags/[tag]/feed.[format=feedFormat]/server.test.ts b/apps/web/src/routes/rss/tags/[tag]/feed.[format=feedFormat]/server.test.ts new file mode 100644 index 00000000..8c708227 --- /dev/null +++ b/apps/web/src/routes/rss/tags/[tag]/feed.[format=feedFormat]/server.test.ts @@ -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[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('https://request.test/site-assets/opengraph') + } +) + +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 & ' + const xml = await (await GET(event())).text() + expect(parseRssFeed(xml).image?.url).toBe('https://assets.example/a&b/images/opengraph.png') + expect(xml).toContain('') + expect(parseRssFeed(xml).image?.title).toBe('Garden & ') +}) diff --git a/apps/web/src/routes/site-assets/[image]/+server.ts b/apps/web/src/routes/site-assets/[image]/+server.ts new file mode 100644 index 00000000..77dec295 --- /dev/null +++ b/apps/web/src/routes/site-assets/[image]/+server.ts @@ -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 diff --git a/apps/web/test/templates/routes.test.ts b/apps/web/test/templates/routes.test.ts index 8d5976b1..f1f6cb34 100644 --- a/apps/web/test/templates/routes.test.ts +++ b/apps/web/test/templates/routes.test.ts @@ -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 { + 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 { 'REQUEST_TIME' ) .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 = + '' + 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() + 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(`https://public.images.test${opengraph}`) + 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 = '' + 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( diff --git a/docs/web/config.example.yaml b/docs/web/config.example.yaml index 031642ab..fac559d6 100644 --- a/docs/web/config.example.yaml +++ b/docs/web/config.example.yaml @@ -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. diff --git a/docs/web/config.md b/docs/web/config.md index 10e67e3d..f9b7c953 100644 --- a/docs/web/config.md +++ b/docs/web/config.md @@ -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 diff --git a/docs/web/deployment.md b/docs/web/deployment.md index b21b658c..385defed 100644 --- a/docs/web/deployment.md +++ b/docs/web/deployment.md @@ -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 diff --git a/packages/web/config/src/index.test.ts b/packages/web/config/src/index.test.ts index 130258ce..37dfaedb 100644 --- a/packages/web/config/src/index.test.ts +++ b/packages/web/config/src/index.test.ts @@ -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(); }); + 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().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().toEqualTypeOf<"url" | "content">(); - expectTypeOf().toEqualTypeOf<"text">(); + expectTypeOf().toEqualTypeOf< + "text" | "images" + >(); + expect(publicConfig.content.images.favicon).toBe("/site-assets/favicon"); + expect(JSON.stringify(publicConfig)).not.toContain("/private/"); expectTypeOf().toEqualTypeOf< "app" | "app_assets" | "luzzle_assets" >(); diff --git a/packages/web/config/src/index.ts b/packages/web/config/src/index.ts index 4f6708f7..887c72e4 100644 --- a/packages/web/config/src/index.ts +++ b/packages/web/config/src/index.ts @@ -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"; diff --git a/packages/web/config/src/lib/config/config.test.ts b/packages/web/config/src/lib/config/config.test.ts index 3679dabc..27fa9981 100644 --- a/packages/web/config/src/lib/config/config.test.ts +++ b/packages/web/config/src/lib/config/config.test.ts @@ -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" }), diff --git a/packages/web/config/src/lib/config/config.ts b/packages/web/config/src/lib/config/config.ts index 961ee985..f34a4fb5 100644 --- a/packages/web/config/src/lib/config/config.ts +++ b/packages/web/config/src/lib/config/config.ts @@ -20,7 +20,11 @@ export interface ConfigSchemaOptions< export type ConfigPublic = { url: Pick; - 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; diff --git a/packages/web/config/src/lib/config/images.test.ts b/packages/web/config/src/lib/config/images.test.ts new file mode 100644 index 00000000..0ea6d69f --- /dev/null +++ b/packages/web/config/src/lib/config/images.test.ts @@ -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().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"); +}); diff --git a/packages/web/config/src/lib/config/input-schema.test.ts b/packages/web/config/src/lib/config/input-schema.test.ts index 1571399e..076317c2 100644 --- a/packages/web/config/src/lib/config/input-schema.test.ts +++ b/packages/web/config/src/lib/config/input-schema.test.ts @@ -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); diff --git a/packages/web/config/src/lib/config/input-schema.ts b/packages/web/config/src/lib/config/input-schema.ts index af35ad3a..e2b18180 100644 --- a/packages/web/config/src/lib/config/input-schema.ts +++ b/packages/web/config/src/lib/config/input-schema.ts @@ -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})|\\$\\{[^}:]+(?::-[^}]*)?\\}`; } } diff --git a/packages/web/config/src/lib/config/schema.test.ts b/packages/web/config/src/lib/config/schema.test.ts index d65bf61d..f7ec967a 100644 --- a/packages/web/config/src/lib/config/schema.test.ts +++ b/packages/web/config/src/lib/config/schema.test.ts @@ -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", diff --git a/packages/web/config/src/lib/config/schema.ts b/packages/web/config/src/lib/config/schema.ts index 1c975b03..9742aab3 100644 --- a/packages/web/config/src/lib/config/schema.ts +++ b/packages/web/config/src/lib/config/schema.ts @@ -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({ diff --git a/packages/web/config/src/lib/config/web.config.schema.json b/packages/web/config/src/lib/config/web.config.schema.json index 33b37212..0a2d3dfb 100644 --- a/packages/web/config/src/lib/config/web.config.schema.json +++ b/packages/web/config/src/lib/config/web.config.schema.json @@ -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, diff --git a/packages/web/templates/config.schema.json b/packages/web/templates/config.schema.json index c942e878..33cdcba5 100644 --- a/packages/web/templates/config.schema.json +++ b/packages/web/templates/config.schema.json @@ -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, -- 2.54.0