feat(embed): standalone widget route /widget/<collection>/<slug>
Renders a single CMS entry as a block in isolation — no header, footer, or preview banner. Lets us share a deep link to a single widget (e.g. the live strommix gauge) for embedding or quick previews without exposing the rest of the page. - New BlockRenderer.svelte: switch over `_type` shared with ContentRows. - Generic `getEntryBySlug(collection, slug, options)` in cms.ts so any collection (live_strommix, deadline_banner, image, ...) is fetchable. - Route group `(embed)/+layout@.svelte` resets the layout chain so no Header/Footer/TopBanner inherit. `x-robots-tag: noindex`. - Optional `?preview=<token>` for drafts. Cache: short s-maxage for live, no-store for preview. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -625,6 +625,42 @@ export async function getPostBySlug(
|
|||||||
return cached("post", key, fetchFn);
|
return cached("post", key, fetchFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic single-entry fetch by `(collection, slug)`. Used by the embed/widget
|
||||||
|
* route that renders a single block standalone.
|
||||||
|
*
|
||||||
|
* Resolves nested references via `_resolve=all` by default so block components
|
||||||
|
* see the same data shape as inside a page row.
|
||||||
|
*/
|
||||||
|
export async function getEntryBySlug<T = Record<string, unknown>>(
|
||||||
|
collection: string,
|
||||||
|
slug: string,
|
||||||
|
options?: ContentGetOptions,
|
||||||
|
): Promise<T | null> {
|
||||||
|
const resolveKey = options?.resolve?.join(",") ?? "all";
|
||||||
|
const fieldsKey = options?.fields?.join(",") ?? "";
|
||||||
|
const depthKey = options?.depth ?? "";
|
||||||
|
const key = `entry:${collection}:${slug}:${options?.locale ?? ""}:${resolveKey}:${depthKey}:${fieldsKey}`;
|
||||||
|
const fetchFn = async (): Promise<T | null> => {
|
||||||
|
const base = getBaseUrl();
|
||||||
|
const u = new URL(`${base}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}`);
|
||||||
|
if (options?.locale) u.searchParams.set("_locale", options.locale);
|
||||||
|
u.searchParams.set("_resolve", options?.resolve?.length ? options.resolve.join(",") : "all");
|
||||||
|
if (typeof options?.depth === "number") u.searchParams.set("_depth", String(options.depth));
|
||||||
|
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);
|
||||||
|
const res = await cmsFetch(u.toString());
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`RustyCMS getEntry failed: ${res.status} for ${collection}/${slug}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
};
|
||||||
|
if (options?.preview || options?.previewDraft) return fetchFn();
|
||||||
|
return cached(collection, 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. */
|
||||||
export async function getPostSlugs(): Promise<string[]> {
|
export async function getPostSlugs(): Promise<string[]> {
|
||||||
const items = await getPosts();
|
const items = await getPosts();
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MarkdownBlock from "./blocks/MarkdownBlock.svelte";
|
||||||
|
import HeadlineBlock from "./blocks/HeadlineBlock.svelte";
|
||||||
|
import HtmlBlock from "./blocks/HtmlBlock.svelte";
|
||||||
|
import IframeBlock from "./blocks/IframeBlock.svelte";
|
||||||
|
import ImageBlock from "./blocks/ImageBlock.svelte";
|
||||||
|
import ImageGalleryBlock from "./blocks/ImageGalleryBlock.svelte";
|
||||||
|
import FilesBlock from "./blocks/FilesBlock.svelte";
|
||||||
|
import YoutubeVideoBlock from "./blocks/YoutubeVideoBlock.svelte";
|
||||||
|
import YoutubeVideoGalleryBlock from "./blocks/YoutubeVideoGalleryBlock.svelte";
|
||||||
|
import QuoteBlock from "./blocks/QuoteBlock.svelte";
|
||||||
|
import QuoteCarouselBlock from "./blocks/QuoteCarouselBlock.svelte";
|
||||||
|
import LinkListBlock from "./blocks/LinkListBlock.svelte";
|
||||||
|
import PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
|
||||||
|
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
|
||||||
|
import CalendarBlock from "./blocks/CalendarBlock.svelte";
|
||||||
|
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
|
||||||
|
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
|
||||||
|
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
|
||||||
|
import StrommixBlock from "./blocks/StrommixBlock.svelte";
|
||||||
|
import type { Translations } from "$lib/translations";
|
||||||
|
import type {
|
||||||
|
ResolvedBlock,
|
||||||
|
MarkdownBlockData,
|
||||||
|
HeadlineBlockData,
|
||||||
|
HtmlBlockData,
|
||||||
|
IframeBlockData,
|
||||||
|
ImageBlockData,
|
||||||
|
ImageGalleryBlockData,
|
||||||
|
FilesBlockData,
|
||||||
|
YoutubeVideoBlockData,
|
||||||
|
YoutubeVideoGalleryBlockData,
|
||||||
|
QuoteBlockData,
|
||||||
|
QuoteCarouselBlockData,
|
||||||
|
LinkListBlockData,
|
||||||
|
PostOverviewBlockData,
|
||||||
|
SearchableTextBlockData,
|
||||||
|
CalendarBlockData,
|
||||||
|
OrganisationsBlockData,
|
||||||
|
OpnFormBlockData,
|
||||||
|
DeadlineBannerBlockData,
|
||||||
|
LiveStrommixBlockData,
|
||||||
|
} from "$lib/block-types";
|
||||||
|
|
||||||
|
let {
|
||||||
|
block,
|
||||||
|
translations = {},
|
||||||
|
}: { block: ResolvedBlock; translations?: Translations | null } = $props();
|
||||||
|
|
||||||
|
const type = $derived((block._type as string) ?? "unknown");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if type === "markdown"}
|
||||||
|
<MarkdownBlock block={block as MarkdownBlockData} />
|
||||||
|
{:else if type === "headline"}
|
||||||
|
<HeadlineBlock block={block as HeadlineBlockData} />
|
||||||
|
{:else if type === "html"}
|
||||||
|
<HtmlBlock block={block as HtmlBlockData} />
|
||||||
|
{:else if type === "iframe"}
|
||||||
|
<IframeBlock block={block as IframeBlockData} translations={translations} />
|
||||||
|
{:else if type === "image"}
|
||||||
|
<ImageBlock block={block as ImageBlockData} />
|
||||||
|
{:else if type === "image_gallery"}
|
||||||
|
<ImageGalleryBlock block={block as ImageGalleryBlockData} />
|
||||||
|
{:else if type === "files"}
|
||||||
|
<FilesBlock block={block as FilesBlockData} />
|
||||||
|
{:else if type === "youtube_video"}
|
||||||
|
<YoutubeVideoBlock block={block as YoutubeVideoBlockData} />
|
||||||
|
{:else if type === "youtube_video_gallery"}
|
||||||
|
<YoutubeVideoGalleryBlock block={block as YoutubeVideoGalleryBlockData} />
|
||||||
|
{:else if type === "quote"}
|
||||||
|
<QuoteBlock block={block as QuoteBlockData} />
|
||||||
|
{:else if type === "quote_carousel"}
|
||||||
|
<QuoteCarouselBlock block={block as QuoteCarouselBlockData} />
|
||||||
|
{:else if type === "link_list"}
|
||||||
|
<LinkListBlock block={block as LinkListBlockData} />
|
||||||
|
{:else if type === "post_overview"}
|
||||||
|
<PostOverviewBlock block={block as PostOverviewBlockData} />
|
||||||
|
{:else if type === "searchable_text"}
|
||||||
|
<SearchableTextBlock block={block as SearchableTextBlockData} translations={translations} />
|
||||||
|
{:else if type === "calendar"}
|
||||||
|
<CalendarBlock block={block as CalendarBlockData} translations={translations} />
|
||||||
|
{:else if type === "organisations"}
|
||||||
|
<OrganisationsBlock block={block as OrganisationsBlockData} />
|
||||||
|
{:else if type === "opnform"}
|
||||||
|
<OpnFormBlock block={block as OpnFormBlockData} />
|
||||||
|
{:else if type === "deadline_banner"}
|
||||||
|
<DeadlineBannerBlock block={block as DeadlineBannerBlockData} />
|
||||||
|
{:else if type === "live_strommix"}
|
||||||
|
<StrommixBlock block={block as LiveStrommixBlockData} />
|
||||||
|
{:else}
|
||||||
|
<div class="rounded border border-erde-200 bg-erde-50 px-3 py-2 text-sm text-erde-800">
|
||||||
|
Unbekannter Block: <code>{type}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../../app.css';
|
||||||
|
import '$lib/iconify-offline';
|
||||||
|
let { children }: { children: import('svelte').Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="embed-root">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(html), :global(body) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.embed-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--color-page-bg, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
import { getEntryBySlug } from "$lib/cms";
|
||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, url, setHeaders }) => {
|
||||||
|
const { collection, slug } = params;
|
||||||
|
const preview = url.searchParams.get("preview") ?? undefined;
|
||||||
|
const locale = url.searchParams.get("_locale") ?? undefined;
|
||||||
|
|
||||||
|
const entry = await getEntryBySlug<Record<string, unknown>>(collection, slug, {
|
||||||
|
resolve: ["all"],
|
||||||
|
preview,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
if (!entry) throw error(404, `Entry not found: ${collection}/${slug}`);
|
||||||
|
|
||||||
|
// _type is needed by BlockRenderer's switch. Server backfills it from the
|
||||||
|
// route param so the renderer sees the same shape as inside a page row.
|
||||||
|
if (typeof entry._type !== "string") {
|
||||||
|
(entry as Record<string, unknown>)._type = collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embeds: skip search engines, mild caching for public reads.
|
||||||
|
setHeaders({
|
||||||
|
"x-robots-tag": "noindex",
|
||||||
|
"cache-control": preview
|
||||||
|
? "private, no-store"
|
||||||
|
: "public, s-maxage=60, stale-while-revalidate=300",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { collection, slug, entry, preview: !!preview };
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BlockRenderer from "$lib/components/BlockRenderer.svelte";
|
||||||
|
import type { ResolvedBlock } from "$lib/block-types";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
const block = $derived(data.entry as ResolvedBlock);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.collection} / {data.slug}</title>
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="widget-shell">
|
||||||
|
<BlockRenderer {block} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.widget-shell {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user