feat(preview): consume rustycms live-preview drafts
`?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:
+18
-1
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user