feat(preview): consume rustycms live-preview drafts
Deploy / verify (push) Successful in 45s
Deploy / deploy (push) Successful in 1m1s

`?preview_draft=<sid>` on any page/post route forwards the signed
session id to the CMS, which renders the in-memory draft instead of
the persisted entry. Layout listens for `rustycms:reload` postMessages
from the admin iframe parent and re-runs `invalidateAll()` to pick up
fresh draft state. CSP `frame-ancestors` is set per-request when
`PUBLIC_PREVIEW_PARENT_ORIGIN` is configured so the admin iframe can
embed the response.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-01 09:24:05 +02:00
parent 3bc7273e71
commit 00d42ebb42
5 changed files with 59 additions and 7 deletions
+18 -1
View File
@@ -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;
};
+6 -2
View File
@@ -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);
}
+27 -2
View File
@@ -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<string, unknown> | Record<string, unknown>[];
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</a>
<div class="flex min-h-screen flex-col text-zinc-900">
{#if pageData?.preview}
{#if pageData?.preview || pageData?.previewDraft}
<div
class="sticky top-0 z-[90] w-full bg-amber-500 text-black shadow-sm"
role="status"
@@ -257,7 +282,7 @@
>
<div class="container-custom flex items-center justify-between gap-3 py-1.5 text-sm">
<span class="font-semibold">
Preview-Modus — Draft sichtbar (nicht indexiert)
{pageData?.previewDraft ? 'Live-Preview — ungespeicherter Draft' : 'Preview-Modus — Draft sichtbar (nicht indexiert)'}
</span>
<a
href={$page.url.pathname}
+4 -1
View File
@@ -27,8 +27,9 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
const { slug } = params;
const translations = locals.translations ?? {};
const preview = url.searchParams.get('preview') ?? undefined;
const previewDraft = url.searchParams.get('preview_draft') ?? undefined;
if (preview) {
if (preview || previewDraft) {
setHeaders({
'cache-control': 'private, no-store, max-age=0',
'x-robots-tag': 'noindex, nofollow',
@@ -42,6 +43,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
// Levels). Kein Body-Stripping hier — Page-Detail braucht alle Felder.
depth: 3,
preview,
previewDraft,
});
if (!page) {
@@ -91,6 +93,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
page,
translations,
preview: !!preview,
previewDraft: !!previewDraft,
seoTitle: page.seoTitle ?? page.headline ?? page.linkName ?? slug,
seoDescription: page.seoDescription ?? page.subheadline ?? '',
robots: (page as { seoMetaRobots?: string }).seoMetaRobots ?? undefined,
+4 -1
View File
@@ -24,8 +24,9 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
const { slug } = params;
const translations = locals.translations ?? {};
const preview = url.searchParams.get('preview') ?? undefined;
const previewDraft = url.searchParams.get('preview_draft') ?? undefined;
if (preview) {
if (preview || previewDraft) {
setHeaders({
'cache-control': 'private, no-store, max-age=0',
'x-robots-tag': 'noindex, nofollow',
@@ -38,6 +39,7 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
// Hard-cap: post → block → image → img reicht in 3 Levels.
depth: 3,
preview,
previewDraft,
});
if (!post) {
@@ -280,6 +282,7 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
return {
post,
preview: !!preview,
previewDraft: !!previewDraft,
postImageUrl,
rawPostImageUrl: postImageField?.url ?? null,
postImageFocal: postImageField?.focal ?? null,