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:
@@ -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