feat(preview): implement preview mode for content management
Deploy / verify (push) Successful in 41s
Deploy / deploy (push) Successful in 1m3s

- Added support for a preview mode in the CMS, allowing draft content to be viewed without being indexed.
- Updated hooks and server logic to handle preview parameters, adjusting cache control and robots tags accordingly.
- Enhanced layout to display a preview notification when in preview mode, improving user experience for content editors.
This commit is contained in:
Peter Meier
2026-04-23 11:11:27 +02:00
parent 9216138a8c
commit 50bb0ef398
5 changed files with 61 additions and 7 deletions
+6 -1
View File
@@ -49,9 +49,12 @@ export const handle: Handle = async ({ event, resolve }) => {
}, },
}); });
const isPreview = event.url.searchParams.has('preview');
if ( if (
event.request.method === 'GET' && event.request.method === 'GET' &&
isCacheablePath(event.url.pathname) && isCacheablePath(event.url.pathname) &&
!isPreview &&
!response.headers.has('cache-control') !response.headers.has('cache-control')
) { ) {
response.headers.set('cache-control', PAGE_CACHE_CONTROL); response.headers.set('cache-control', PAGE_CACHE_CONTROL);
@@ -64,7 +67,9 @@ export const handle: Handle = async ({ event, resolve }) => {
) { ) {
response.headers.set( response.headers.set(
'x-robots-tag', 'x-robots-tag',
'index, follow, max-image-preview:large, max-snippet:-1', isPreview
? 'noindex, nofollow'
: 'index, follow, max-image-preview:large, max-snippet:-1',
); );
} }
+12 -4
View File
@@ -229,6 +229,8 @@ export type ContentGetOptions = {
locale?: string; locale?: string;
/** Felder, deren Referenzen aufgelöst werden (z. B. ["row1Content", "row2Content", "row3Content"]). */ /** Felder, deren Referenzen aufgelöst werden (z. B. ["row1Content", "row2Content", "row3Content"]). */
resolve?: string[]; resolve?: string[];
/** Signierter Preview-Token (HMAC), entsperrt Drafts für genau diesen Eintrag. Bypass Cache. */
preview?: string;
}; };
/** Führenden + abschließenden Slash entfernen (CMS "slug": "/about"; Route-Param mit trailing slash). */ /** Führenden + abschließenden Slash entfernen (CMS "slug": "/about"; Route-Param mit trailing slash). */
@@ -245,13 +247,14 @@ export async function getPageBySlug(
): Promise<PageEntry | null> { ): Promise<PageEntry | null> {
const resolveKey = options?.resolve?.join(",") ?? ""; const resolveKey = options?.resolve?.join(",") ?? "";
const key = `page:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`; const key = `page:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
return cached("page", key, async () => { const fetchFn = async () => {
const base = getBaseUrl(); const base = getBaseUrl();
const trySlug = async (s: string): Promise<Response> => { const trySlug = async (s: string): Promise<Response> => {
const u = new URL(`${base}/api/content/page/${encodeURIComponent(s)}`); const u = new URL(`${base}/api/content/page/${encodeURIComponent(s)}`);
if (options?.locale) u.searchParams.set("_locale", options.locale); if (options?.locale) u.searchParams.set("_locale", options.locale);
if (options?.resolve?.length) if (options?.resolve?.length)
u.searchParams.set("_resolve", options.resolve.join(",")); u.searchParams.set("_resolve", options.resolve.join(","));
if (options?.preview) u.searchParams.set("preview", options.preview);
return fetch(u.toString()); return fetch(u.toString());
}; };
const normSlug = normalizePageSlug(slug); const normSlug = normalizePageSlug(slug);
@@ -277,7 +280,9 @@ export async function getPageBySlug(
); );
} }
return (await res.json()) as PageEntry; return (await res.json()) as PageEntry;
}); };
if (options?.preview) return fetchFn();
return cached("page", key, fetchFn);
} }
/** Für Page-URLs wird `slug` bevorzugt (nicht _slug). Führender Slash wird ignoriert. */ /** Für Page-URLs wird `slug` bevorzugt (nicht _slug). Führender Slash wird ignoriert. */
@@ -435,13 +440,14 @@ export async function getPostBySlug(
): Promise<PostEntry | null> { ): Promise<PostEntry | null> {
const resolveKey = options?.resolve?.join(",") ?? ""; const resolveKey = options?.resolve?.join(",") ?? "";
const key = `post:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`; const key = `post:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
return cached("post", key, async () => { const fetchFn = async () => {
const base = getBaseUrl(); const base = getBaseUrl();
const trySlug = async (s: string): Promise<Response> => { const trySlug = async (s: string): Promise<Response> => {
const u = new URL(`${base}/api/content/post/${encodeURIComponent(s)}`); const u = new URL(`${base}/api/content/post/${encodeURIComponent(s)}`);
if (options?.locale) u.searchParams.set("_locale", options.locale); if (options?.locale) u.searchParams.set("_locale", options.locale);
if (options?.resolve?.length) if (options?.resolve?.length)
u.searchParams.set("_resolve", options.resolve.join(",")); u.searchParams.set("_resolve", options.resolve.join(","));
if (options?.preview) u.searchParams.set("preview", options.preview);
return fetch(u.toString()); return fetch(u.toString());
}; };
const normSlug = normalizePostSlug(slug); const normSlug = normalizePostSlug(slug);
@@ -467,7 +473,9 @@ export async function getPostBySlug(
); );
} }
return (await res.json()) as PostEntry; return (await res.json()) as PostEntry;
}); };
if (options?.preview) return fetchFn();
return cached("post", key, fetchFn);
} }
/** Für Post-URLs wird slug bevorzugt (nicht _slug). Führender Slash wird ignoriert. */ /** Für Post-URLs wird slug bevorzugt (nicht _slug). Führender Slash wird ignoriert. */
+21
View File
@@ -21,6 +21,7 @@
socialImage?: string; socialImage?: string;
robots?: string; robots?: string;
keywords?: string; keywords?: string;
preview?: 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?: {
@@ -248,6 +249,26 @@
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}
<div
class="sticky top-0 z-[90] w-full bg-amber-500 text-black shadow-sm"
role="status"
aria-live="polite"
>
<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)
</span>
<a
href={$page.url.pathname}
class="underline hover:no-underline"
rel="nofollow"
>
Preview verlassen
</a>
</div>
</div>
{/if}
<Header <Header
links={data.headerLinks} links={data.headerLinks}
socialLinks={data.socialLinks} socialLinks={data.socialLinks}
+11 -1
View File
@@ -23,13 +23,22 @@ function focalToCss(focal: { x: number; y: number } | undefined): string | null
return `${(focal.x * 100).toFixed(2)}% ${(focal.y * 100).toFixed(2)}%`; return `${(focal.x * 100).toFixed(2)}% ${(focal.y * 100).toFixed(2)}%`;
} }
export const load: PageServerLoad = async ({ params, locals, fetch }) => { export const load: PageServerLoad = async ({ params, locals, fetch, url, setHeaders }) => {
const { slug } = params; const { slug } = params;
const translations = locals.translations ?? {}; const translations = locals.translations ?? {};
const preview = url.searchParams.get('preview') ?? undefined;
if (preview) {
setHeaders({
'cache-control': 'private, no-store, max-age=0',
'x-robots-tag': 'noindex, nofollow',
});
}
const page = await getPageBySlug(slug, { const page = await getPageBySlug(slug, {
locale: 'de', locale: 'de',
resolve: PAGE_RESOLVE, resolve: PAGE_RESOLVE,
preview,
}); });
if (!page) { if (!page) {
@@ -78,6 +87,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch }) => {
return { return {
page, page,
translations, translations,
preview: !!preview,
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,
+11 -1
View File
@@ -20,13 +20,22 @@ import { marked } from 'marked';
marked.setOptions({ gfm: true }); marked.setOptions({ gfm: true });
export const load: PageServerLoad = async ({ params, locals, url }) => { 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;
if (preview) {
setHeaders({
'cache-control': 'private, no-store, max-age=0',
'x-robots-tag': 'noindex, nofollow',
});
}
const post = await getPostBySlug(slug, { const post = await getPostBySlug(slug, {
locale: 'de', locale: 'de',
resolve: POST_RESOLVE, resolve: POST_RESOLVE,
preview,
}); });
if (!post) { if (!post) {
@@ -202,6 +211,7 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
return { return {
post, post,
preview: !!preview,
postImageUrl, postImageUrl,
rawPostImageUrl: postImageField?.url ?? null, rawPostImageUrl: postImageField?.url ?? null,
postImageFocal: postImageField?.focal ?? null, postImageFocal: postImageField?.focal ?? null,