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 (
|
if (
|
||||||
event.request.method === 'GET' &&
|
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;
|
return response;
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-2
@@ -235,6 +235,8 @@ export type ContentGetOptions = {
|
|||||||
fields?: string[];
|
fields?: string[];
|
||||||
/** Signierter Preview-Token (HMAC), entsperrt Drafts für genau diesen Eintrag. Bypass Cache. */
|
/** Signierter Preview-Token (HMAC), entsperrt Drafts für genau diesen Eintrag. Bypass Cache. */
|
||||||
preview?: string;
|
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)
|
if (options?.fields?.length)
|
||||||
u.searchParams.set("_fields", options.fields.join(","));
|
u.searchParams.set("_fields", options.fields.join(","));
|
||||||
if (options?.preview) u.searchParams.set("preview", options.preview);
|
if (options?.preview) u.searchParams.set("preview", options.preview);
|
||||||
|
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
||||||
return fetch(u.toString());
|
return fetch(u.toString());
|
||||||
};
|
};
|
||||||
const normSlug = normalizePageSlug(slug);
|
const normSlug = normalizePageSlug(slug);
|
||||||
@@ -351,7 +354,7 @@ export async function getPageBySlug(
|
|||||||
}
|
}
|
||||||
return (await res.json()) as PageEntry;
|
return (await res.json()) as PageEntry;
|
||||||
};
|
};
|
||||||
if (options?.preview) return fetchFn();
|
if (options?.preview || options?.previewDraft) return fetchFn();
|
||||||
return cached("page", key, fetchFn);
|
return cached("page", key, fetchFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,6 +576,7 @@ export async function getPostBySlug(
|
|||||||
if (options?.fields?.length)
|
if (options?.fields?.length)
|
||||||
u.searchParams.set("_fields", options.fields.join(","));
|
u.searchParams.set("_fields", options.fields.join(","));
|
||||||
if (options?.preview) u.searchParams.set("preview", options.preview);
|
if (options?.preview) u.searchParams.set("preview", options.preview);
|
||||||
|
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
||||||
return fetch(u.toString());
|
return fetch(u.toString());
|
||||||
};
|
};
|
||||||
const normSlug = normalizePostSlug(slug);
|
const normSlug = normalizePostSlug(slug);
|
||||||
@@ -599,7 +603,7 @@ export async function getPostBySlug(
|
|||||||
}
|
}
|
||||||
return (await res.json()) as PostEntry;
|
return (await res.json()) as PostEntry;
|
||||||
};
|
};
|
||||||
if (options?.preview) return fetchFn();
|
if (options?.preview || options?.previewDraft) return fetchFn();
|
||||||
return cached("post", key, fetchFn);
|
return cached("post", key, fetchFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,33 @@
|
|||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
import { ensureTransformedImage } from '$lib/rusty-image';
|
import { ensureTransformedImage } from '$lib/rusty-image';
|
||||||
import { DEFAULT_SOCIAL_IMAGE_URL, SOCIAL_IMAGE_DIMENSIONS } from '$lib/constants';
|
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();
|
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
|
// Per-page SEO data from $page.data
|
||||||
const pageData = $derived($page.data as {
|
const pageData = $derived($page.data as {
|
||||||
seoTitle?: string;
|
seoTitle?: string;
|
||||||
@@ -22,6 +46,7 @@
|
|||||||
robots?: string;
|
robots?: string;
|
||||||
keywords?: string;
|
keywords?: string;
|
||||||
preview?: boolean;
|
preview?: boolean;
|
||||||
|
previewDraft?: boolean;
|
||||||
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
||||||
breadcrumbItems?: { href?: string; label: string }[];
|
breadcrumbItems?: { href?: string; label: string }[];
|
||||||
topBanner?: {
|
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"
|
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>
|
>Zum Inhalt springen</a>
|
||||||
<div class="flex min-h-screen flex-col text-zinc-900">
|
<div class="flex min-h-screen flex-col text-zinc-900">
|
||||||
{#if pageData?.preview}
|
{#if pageData?.preview || pageData?.previewDraft}
|
||||||
<div
|
<div
|
||||||
class="sticky top-0 z-[90] w-full bg-amber-500 text-black shadow-sm"
|
class="sticky top-0 z-[90] w-full bg-amber-500 text-black shadow-sm"
|
||||||
role="status"
|
role="status"
|
||||||
@@ -257,7 +282,7 @@
|
|||||||
>
|
>
|
||||||
<div class="container-custom flex items-center justify-between gap-3 py-1.5 text-sm">
|
<div class="container-custom flex items-center justify-between gap-3 py-1.5 text-sm">
|
||||||
<span class="font-semibold">
|
<span class="font-semibold">
|
||||||
Preview-Modus — Draft sichtbar (nicht indexiert)
|
{pageData?.previewDraft ? 'Live-Preview — ungespeicherter Draft' : 'Preview-Modus — Draft sichtbar (nicht indexiert)'}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
href={$page.url.pathname}
|
href={$page.url.pathname}
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
|
|||||||
const { slug } = params;
|
const { slug } = params;
|
||||||
const translations = locals.translations ?? {};
|
const translations = locals.translations ?? {};
|
||||||
const preview = url.searchParams.get('preview') ?? undefined;
|
const preview = url.searchParams.get('preview') ?? undefined;
|
||||||
|
const previewDraft = url.searchParams.get('preview_draft') ?? undefined;
|
||||||
|
|
||||||
if (preview) {
|
if (preview || previewDraft) {
|
||||||
setHeaders({
|
setHeaders({
|
||||||
'cache-control': 'private, no-store, max-age=0',
|
'cache-control': 'private, no-store, max-age=0',
|
||||||
'x-robots-tag': 'noindex, nofollow',
|
'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.
|
// Levels). Kein Body-Stripping hier — Page-Detail braucht alle Felder.
|
||||||
depth: 3,
|
depth: 3,
|
||||||
preview,
|
preview,
|
||||||
|
previewDraft,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
@@ -91,6 +93,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
|
|||||||
page,
|
page,
|
||||||
translations,
|
translations,
|
||||||
preview: !!preview,
|
preview: !!preview,
|
||||||
|
previewDraft: !!previewDraft,
|
||||||
seoTitle: page.seoTitle ?? page.headline ?? page.linkName ?? slug,
|
seoTitle: page.seoTitle ?? page.headline ?? page.linkName ?? slug,
|
||||||
seoDescription: page.seoDescription ?? page.subheadline ?? '',
|
seoDescription: page.seoDescription ?? page.subheadline ?? '',
|
||||||
robots: (page as { seoMetaRobots?: string }).seoMetaRobots ?? undefined,
|
robots: (page as { seoMetaRobots?: string }).seoMetaRobots ?? undefined,
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
|
|||||||
const { slug } = params;
|
const { slug } = params;
|
||||||
const translations = locals.translations ?? {};
|
const translations = locals.translations ?? {};
|
||||||
const preview = url.searchParams.get('preview') ?? undefined;
|
const preview = url.searchParams.get('preview') ?? undefined;
|
||||||
|
const previewDraft = url.searchParams.get('preview_draft') ?? undefined;
|
||||||
|
|
||||||
if (preview) {
|
if (preview || previewDraft) {
|
||||||
setHeaders({
|
setHeaders({
|
||||||
'cache-control': 'private, no-store, max-age=0',
|
'cache-control': 'private, no-store, max-age=0',
|
||||||
'x-robots-tag': 'noindex, nofollow',
|
'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.
|
// Hard-cap: post → block → image → img reicht in 3 Levels.
|
||||||
depth: 3,
|
depth: 3,
|
||||||
preview,
|
preview,
|
||||||
|
previewDraft,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
@@ -280,6 +282,7 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
|
|||||||
return {
|
return {
|
||||||
post,
|
post,
|
||||||
preview: !!preview,
|
preview: !!preview,
|
||||||
|
previewDraft: !!previewDraft,
|
||||||
postImageUrl,
|
postImageUrl,
|
||||||
rawPostImageUrl: postImageField?.url ?? null,
|
rawPostImageUrl: postImageField?.url ?? null,
|
||||||
postImageFocal: postImageField?.focal ?? null,
|
postImageFocal: postImageField?.focal ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user