diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 463a738..6049c45 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -49,7 +49,10 @@ export const handle: Handle = async ({ event, resolve }) => { }, }); - const isPreview = event.url.searchParams.has('preview'); + const isPreview = + event.url.searchParams.has('preview') || + event.url.searchParams.has('preview_draft'); + const isPreviewDraft = event.url.searchParams.has('preview_draft'); if ( event.request.method === 'GET' && @@ -73,5 +76,19 @@ export const handle: Handle = async ({ event, resolve }) => { ); } + // Live-preview: allow the admin iframe to embed this response. The admin + // origin is configured via PUBLIC_PREVIEW_PARENT_ORIGIN; when unset we fall + // back to wildcard (dev only). Outside live-preview we leave existing + // headers alone so production hardening (X-Frame-Options / CSP from Caddy) + // stays intact. + if (isPreviewDraft) { + const parent = (process.env.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').trim(); + response.headers.set( + 'content-security-policy', + `frame-ancestors 'self' ${parent || '*'}`, + ); + response.headers.delete('x-frame-options'); + } + return response; }; diff --git a/src/lib/cms.ts b/src/lib/cms.ts index 881661d..b3745ab 100644 --- a/src/lib/cms.ts +++ b/src/lib/cms.ts @@ -235,6 +235,8 @@ export type ContentGetOptions = { fields?: string[]; /** Signierter Preview-Token (HMAC), entsperrt Drafts für genau diesen Eintrag. Bypass Cache. */ preview?: string; + /** Signierte Live-Preview-Session-ID — Server rendert In-Memory-Draft statt persistierten Eintrag. Bypass Cache. */ + previewDraft?: string; }; // --------------------------------------------------------------------------- @@ -325,6 +327,7 @@ export async function getPageBySlug( if (options?.fields?.length) u.searchParams.set("_fields", options.fields.join(",")); if (options?.preview) u.searchParams.set("preview", options.preview); + if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft); return fetch(u.toString()); }; const normSlug = normalizePageSlug(slug); @@ -351,7 +354,7 @@ export async function getPageBySlug( } return (await res.json()) as PageEntry; }; - if (options?.preview) return fetchFn(); + if (options?.preview || options?.previewDraft) return fetchFn(); return cached("page", key, fetchFn); } @@ -573,6 +576,7 @@ export async function getPostBySlug( if (options?.fields?.length) u.searchParams.set("_fields", options.fields.join(",")); if (options?.preview) u.searchParams.set("preview", options.preview); + if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft); return fetch(u.toString()); }; const normSlug = normalizePostSlug(slug); @@ -599,7 +603,7 @@ export async function getPostBySlug( } return (await res.json()) as PostEntry; }; - if (options?.preview) return fetchFn(); + if (options?.preview || options?.previewDraft) return fetchFn(); return cached("post", key, fetchFn); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 73508c6..f701b57 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -11,9 +11,33 @@ import type { LayoutData } from './$types'; import { ensureTransformedImage } from '$lib/rusty-image'; import { DEFAULT_SOCIAL_IMAGE_URL, SOCIAL_IMAGE_DIMENSIONS } from '$lib/constants'; + import { onMount } from 'svelte'; + import { invalidateAll } from '$app/navigation'; let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props(); + // Live-preview reload bridge: when the page was loaded with + // `?preview_draft=...`, accept `rustycms:reload` postMessage events from the + // admin iframe parent and re-run the SvelteKit `load()` so the iframe + // reflects the latest in-memory draft. We re-run instead of `location.reload()` + // to keep scroll position and avoid the full document re-mount cost. + onMount(() => { + if (typeof window === 'undefined') return; + const isPreviewDraft = window.location.search.includes('preview_draft='); + if (!isPreviewDraft) return; + const allowed = (import.meta.env.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').toString().trim(); + const handler = (event: MessageEvent) => { + if (allowed && event.origin !== allowed) return; + const data = event.data; + if (!data) return; + if (data === 'rustycms:reload' || (typeof data === 'object' && data.type === 'rustycms:reload')) { + void invalidateAll(); + } + }; + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }); + // Per-page SEO data from $page.data const pageData = $derived($page.data as { seoTitle?: string; @@ -22,6 +46,7 @@ robots?: string; keywords?: string; preview?: boolean; + previewDraft?: boolean; jsonLd?: Record | Record[]; breadcrumbItems?: { href?: string; label: string }[]; topBanner?: { @@ -249,7 +274,7 @@ class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:bg-white focus:px-3 focus:py-2 focus:shadow focus:outline focus:outline-2 focus:outline-wald-600 focus:rounded-sm" >Zum Inhalt springen
- {#if pageData?.preview} + {#if pageData?.preview || pageData?.previewDraft}
- Preview-Modus — Draft sichtbar (nicht indexiert) + {pageData?.previewDraft ? 'Live-Preview — ungespeicherter Draft' : 'Preview-Modus — Draft sichtbar (nicht indexiert)'}