feat(preview): implement preview mode for content management
- 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:
+6
-1
@@ -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
@@ -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,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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user