/** * RustyCMS-Client: OpenAPI-Spec laden, Page-Content abrufen. * Basis-URL über Umgebungsvariable PUBLIC_CMS_URL (z.B. http://localhost:3000). * * Konvention: slug = URL (für Links/Pfade), _slug = API/interne ID (für GET-Requests). * Typen für Page und API-Antworten kommen aus der generierten OpenAPI-Spec. * Nach Schema-Änderungen im CMS: `npm run generate:api-types` (RustyCMS muss laufen). */ import type { components, operations } from "./cms-api.generated"; import { env } from "$env/dynamic/public"; const getBaseUrl = (): string => { const url = env.PUBLIC_CMS_URL || import.meta.env.PUBLIC_CMS_URL || "http://localhost:3000"; return (typeof url === "string" && url.trim() ? url : "http://localhost:3000").replace(/\/$/, ""); }; /** * Default-Timeout für CMS-Calls (ms). Verhindert hängende SSR-Worker bei * langsamem oder unerreichbarem CMS. Override per Call via `init.signal` * oder global per `PUBLIC_CMS_FETCH_TIMEOUT_MS`. */ const FETCH_TIMEOUT_MS = Number(env.PUBLIC_CMS_FETCH_TIMEOUT_MS) || 5_000; /** * Wrapper um `fetch` mit Default-Timeout. Wenn ein eigener `signal` * mitgegeben wird, bleibt der unverändert; sonst wird `AbortSignal.timeout` * gesetzt. Fehler-Mapping wie nativer fetch — Timeout wirft `AbortError`. */ async function cmsFetch(input: string | URL, init?: RequestInit): Promise { const signal = init?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS); return fetch(input, { ...init, signal }); } /** Page-Eintrag aus der API (aus OpenAPI-Spec generiert). */ export type PageEntry = components["schemas"]["page"]; /** Antwort von GET /api/content/page (aus OpenAPI-Spec generiert). */ export type PageListResponse = operations["listPage"]["responses"][200]["content"]["application/json"]; /** Page-Config (Site-Einstellungen, wie in windwiderstand). Optional: homePage für Startseiten-Slug. */ export type PageConfigEntry = components["schemas"]["page_config"] & { /** Referenz auf die Page für die Startseite (Slug oder { _slug }). */ homePage?: string | { _slug?: string }; }; export type PageConfigListResponse = operations["listPageConfig"]["responses"][200]["content"]["application/json"]; export type OpenApiSpec = { openapi: string; info: { title: string; version: string }; paths: Record; components?: { schemas?: Record }; }; let openApiCache: OpenApiSpec | null = null; /** * TTL-Cache pro Collection (ms). Webhooks purgen bei Mutation → hohe TTL unkritisch. * Override via PUBLIC_CMS_CACHE_TTL_MS_ falls nötig. */ const CACHE_TTL: Record = { page: 5 * 60_000, page_config: 10 * 60_000, navigation: 10 * 60_000, footer: 10 * 60_000, post: 60_000, tag: 5 * 60_000, text_fragment: 5 * 60_000, fullwidth_banner: 5 * 60_000, calendar: 60_000, calendar_item: 60_000, translation: 5 * 60_000, translation_bundle: 5 * 60_000, openapi: 10 * 60_000, default: 60_000, }; type CacheEntry = { value: T; expires: number }; const cache = new Map>(); const inflight = new Map>(); function ttlFor(collection: string): number { return CACHE_TTL[collection] ?? CACHE_TTL.default; } /** * Cached fetch mit TTL + in-flight dedup. Key-Format: "::". * Bei Fehler wird nichts gecacht. */ async function cached( collection: string, key: string, fn: () => Promise, ): Promise { if (import.meta.env.DEV) return fn(); const hit = cache.get(key); if (hit && hit.expires > Date.now()) return hit.value as T; const pending = inflight.get(key) as Promise | undefined; if (pending) return pending; const promise = (async () => { try { const value = await fn(); cache.set(key, { value, expires: Date.now() + ttlFor(collection) }); return value; } finally { inflight.delete(key); } })(); inflight.set(key, promise); return promise; } /** Purge Cache für eine Collection (z.B. nach Webhook). */ export function invalidateCollection(collection: string): number { let n = 0; for (const k of cache.keys()) { if (k.startsWith(`${collection}:`)) { cache.delete(k); n++; } } return n; } /** Purge kompletter Cache. */ export function invalidateAll(): void { cache.clear(); openApiCache = null; } /** Stats/Debug. */ export function cacheStats(): { size: number; keys: string[] } { return { size: cache.size, keys: [...cache.keys()] }; } /** * Lädt die OpenAPI-Spezifikation von GET /api-docs/openapi.json. * Wird gecacht (einmal pro Request/Build). */ export async function fetchOpenApi(): Promise { if (openApiCache && !import.meta.env.DEV) return openApiCache; const base = getBaseUrl(); const res = await cmsFetch(`${base}/api-docs/openapi.json`); if (!res.ok) { throw new Error( `RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`, ); } const spec = (await res.json()) as OpenApiSpec; openApiCache = spec; return spec; } /** * Alle Page-Einträge (GET /api/content/page). TTL-gecacht. */ export async function getPages(): Promise { return cached("page", "page:list:", async () => { const base = getBaseUrl(); const res = await cmsFetch(`${base}/api/content/page`); if (!res.ok) { throw new Error( `RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`, ); } const data = (await res.json()) as PageListResponse; return data.items ?? []; }); } /** * Alle page_config-Einträge (GET /api/content/page_config). */ export async function getPageConfigs(options?: { locale?: string; per_page?: number; }): Promise { const locale = options?.locale ?? ""; const per = options?.per_page ?? 50; const key = `page_config:list:${locale}:${per}`; return cached("page_config", key, async () => { const base = getBaseUrl(); const url = new URL(`${base}/api/content/page_config`); if (locale) url.searchParams.set("_locale", locale); url.searchParams.set("_per_page", String(per)); const res = await cmsFetch(url.toString()); if (!res.ok) { throw new Error( `RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`, ); } const data = (await res.json()) as PageConfigListResponse; return (data.items ?? []) as PageConfigEntry[]; }); } /** * Page-Config anhand Slug (z. B. "default"). Gecacht pro Request (Key: slug + options). */ export async function getPageConfigBySlug( slug: string, options?: { locale?: string; resolve?: string }, ): Promise { const opts = options ?? {}; const key = `page_config:slug:${slug}:${opts.locale ?? ""}:${opts.resolve ?? ""}`; return cached("page_config", key, async () => { const base = getBaseUrl(); const url = new URL( `${base}/api/content/page_config/${encodeURIComponent(slug)}`, ); if (opts.locale) url.searchParams.set("_locale", opts.locale); if (opts.resolve) url.searchParams.set("_resolve", opts.resolve); const res = await cmsFetch(url.toString()); if (res.status === 404) return null; if (!res.ok) { throw new Error( `RustyCMS get page_config failed: ${res.status} for slug "${slug}".`, ); } return (await res.json()) as PageConfigEntry; }); } /** * Startseiten-Slug aus page_config. */ export async function getHomePageSlugFromConfig(options?: { locale?: string; }): Promise { const config = (await getPageConfigBySlug("page-config-default", options)) ?? (await getPageConfigBySlug("default", options)) ?? (await getPageConfigs({ locale: options?.locale, per_page: 1 }))[0] ?? null; if (!config?.homePage) return null; const raw = config.homePage; if (typeof raw === "string") return raw.trim() || null; const slug = (raw as { _slug?: string })?._slug?.trim(); return slug ?? null; } /** Optionen für Content-Get (z. B. _resolve, _locale). */ export type ContentGetOptions = { locale?: string; /** Felder, deren Referenzen aufgelöst werden (z. B. ["row1Content", "row2Content", "row3Content"]). */ resolve?: string[]; /** Cap für Resolve-Rekursionstiefe (0..=10). Ohne Wert: legacy unbegrenzt. */ depth?: number; /** Sparse-Fieldset, z. B. ["title", "author.name"]. Punktnotation triggert implicit _resolve. */ fields?: string[]; /** Signierter Preview-Token (HMAC), entsperrt Drafts für genau diesen Eintrag. Bypass Cache. */ preview?: string; /** Signierte Live-Preview-Session-ID — Server rendert In-Memory-Draft statt persistierten Eintrag. Bypass Cache. */ previewDraft?: string; }; // --------------------------------------------------------------------------- // Batch endpoint (POST /api/content/_batch) // --------------------------------------------------------------------------- /** Eine Sub-Anfrage im Batch. `id` wird im Response als Schlüssel zurückgegeben. */ export type BatchRequest = { id: string; collection: string; slug: string; environment?: string; locale?: string; resolve?: string; depth?: number; fields?: string[]; preview?: string; raw_assets?: boolean; }; /** Pro-Sub-Result: Erfolg (`status: 200, data`) oder Fehler (`status: 4xx/5xx, error`). */ export type BatchResult = | { status: 200; data: T } | { status: number; error: string }; /** Antwort von POST /api/content/_batch. */ export type BatchResponse = { results: Record }; /** * Schickt mehrere Lookups in einem HTTP-Roundtrip an den CMS-Batch-Endpoint. * * - Outer-Status ist immer 200 wenn der Envelope ok ist; pro Sub-Request * findet sich der eigentliche Status in `results..status`. * - Cap pro Call wird vom Server erzwungen (`RUSTYCMS_BATCH_MAX_REQUESTS`, * default 50). Über dem Cap → 400 mit Detail-Message. * - Kein TTL-Cache: Batch-Calls sind POST und tragen einen serialisierten * Body, der sich pro Call unterscheidet. Wer Cache will, soll die * Single-GET-Funktionen nehmen. */ export async function batchFetch(requests: BatchRequest[]): Promise { if (requests.length === 0) return { results: {} }; const base = getBaseUrl(); const res = await cmsFetch(`${base}/api/content/_batch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requests }), }); if (!res.ok) { const detail = await res.text().catch(() => ""); throw new Error( `RustyCMS batch failed: ${res.status} ${res.statusText}. ${detail}`, ); } return (await res.json()) as BatchResponse; } /** Type-narrowing-Helper: liefert `data`, wenn der Sub-Request 200 lieferte, sonst `null`. */ export function batchData(result: BatchResult | undefined): T | null { if (!result) return null; return result.status === 200 ? (result as { data: T }).data : null; } /** Führenden + abschließenden Slash entfernen (CMS "slug": "/about"; Route-Param mit trailing slash). */ function normalizePageSlug(s: string | undefined): string { return (s ?? "").replace(/^\/+|\/+$/g, "").trim(); } /** * Ein Page-Eintrag nach Slug (GET /api/content/page/:slug). */ export async function getPageBySlug( slug: string, options?: ContentGetOptions, ): Promise { const resolveKey = options?.resolve?.join(",") ?? ""; const fieldsKey = options?.fields?.join(",") ?? ""; const depthKey = options?.depth ?? ""; const key = `page:slug:${slug}:${options?.locale ?? ""}:${resolveKey}:${depthKey}:${fieldsKey}`; const fetchFn = async () => { const base = getBaseUrl(); const trySlug = async (s: string): Promise => { const u = new URL(`${base}/api/content/page/${encodeURIComponent(s)}`); if (options?.locale) u.searchParams.set("_locale", options.locale); if (options?.resolve?.length) u.searchParams.set("_resolve", options.resolve.join(",")); 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); return cmsFetch(u.toString()); }; const normSlug = normalizePageSlug(slug); let res = await trySlug(normSlug); if (res.status === 404) { res = await trySlug("/" + normSlug); } if (res.status === 404) { const items = await getPages(); const match = items.find( (p) => normalizePageSlug(p.slug) === normSlug || normalizePageSlug(p._slug) === normSlug, ); if (match?._slug) { res = await trySlug(match._slug); } } if (res.status === 404) return null; if (!res.ok) { throw new Error( `RustyCMS get page failed: ${res.status} for slug "${slug}".`, ); } return (await res.json()) as PageEntry; }; if (options?.preview || options?.previewDraft) return fetchFn(); return cached("page", key, fetchFn); } /** Für Page-URLs wird `slug` bevorzugt (nicht _slug). Führender Slash wird ignoriert. */ export async function getPageSlugs(): Promise { const items = await getPages(); return items.map((p) => normalizePageSlug(p.slug ?? p._slug)).filter(Boolean); } /** Magere Page-Stub-Daten für Navigation/Slug-Maps — nur Label-Felder, kein Body. */ export type PageStub = Pick< PageEntry, "_slug" | "slug" | "linkName" | "name" | "headline" >; /** * Page-Liste auf nur die Felder reduziert, die für Slug→Label/Slug→URL-Maps * gebraucht werden. Spart Body/Rows — typisch Faktor 10-50× kleiner. */ export async function getPageStubs(options?: { locale?: string }): Promise { const locale = options?.locale ?? ""; const key = `page:stubs:${locale}`; return cached("page", key, async () => { const base = getBaseUrl(); const url = new URL(`${base}/api/content/page`); url.searchParams.set("_fields", "_slug,slug,linkName,name,headline"); url.searchParams.set("_per_page", "1000"); if (locale) url.searchParams.set("_locale", locale); const res = await cmsFetch(url.toString()); if (!res.ok) throw new Error(`RustyCMS list page (stubs) failed: ${res.status}`); const data = (await res.json()) as { items?: PageStub[] }; return data.items ?? []; }); } /** Magere Post-Stubs für Navigation/Slug-Maps — kein Body, keine Rows. */ export type PostStub = Pick< PostEntry, "_slug" | "slug" | "linkName" | "headline" >; /** Post-Liste reduziert auf Slug+Label-Felder. */ export async function getPostStubs(options?: { locale?: string }): Promise { const locale = options?.locale ?? ""; const key = `post:stubs:${locale}`; return cached("post", key, async () => { const base = getBaseUrl(); const url = new URL(`${base}/api/content/post`); url.searchParams.set("_fields", "_slug,slug,linkName,headline"); url.searchParams.set("_per_page", "1000"); if (locale) url.searchParams.set("_locale", locale); const res = await cmsFetch(url.toString()); if (!res.ok) throw new Error(`RustyCMS list post (stubs) failed: ${res.status}`); const data = (await res.json()) as { items?: PostStub[] }; return data.items ?? []; }); } /** Navigation (Single Source of Truth: RustyCMS OpenAPI). */ export type NavigationEntry = components["schemas"]["navigation"]; /** Ein Eintrag aus navigation.links (Referenz { _slug, _type } oder Slug-String). */ export type NavLinkEntry = NavigationEntry["links"] extends (infer L)[] ? L : never; /** Antwort von GET /api/content/navigation (paginiert). */ export type NavigationListResponse = operations["listNavigation"]["responses"][200]["content"]["application/json"]; /** * Alle Navigation-Einträge (GET /api/content/navigation). Gecacht pro Request. */ export async function getNavigations(options?: { locale?: string; page?: number; per_page?: number; resolve?: string; }): Promise<{ items: NavigationEntry[]; page: number; per_page: number; total: number; total_pages: number; }> { const opts = options ?? {}; const key = `navigation:list:${opts.locale ?? ""}:${opts.page ?? 1}:${opts.per_page ?? 50}:${opts.resolve ?? ""}`; return cached("navigation", key, async () => { const base = getBaseUrl(); const url = new URL(`${base}/api/content/navigation`); if (opts.locale) url.searchParams.set("_locale", opts.locale); if (opts.resolve) url.searchParams.set("_resolve", opts.resolve); url.searchParams.set("_page", String(opts.page ?? 1)); url.searchParams.set("_per_page", String(opts.per_page ?? 50)); const res = await cmsFetch(url.toString()); if (!res.ok) { throw new Error( `RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`, ); } const data = (await res.json()) as NavigationListResponse; return { items: data.items ?? [], page: data.page ?? 1, per_page: data.per_page ?? 50, total: data.total ?? 0, total_pages: data.total_pages ?? 1, }; }); } /** Keys für Navigation. */ export const NavigationKeys = { header: "navigation-header", socialMedia: "navigation-social-media", footer: "navigation-footer", } as const; /** * Navigation anhand Key/Slug aus der Liste holen. */ export async function getNavigationByKey( key: string, options?: { locale?: string; resolve?: string }, ): Promise { const { items } = await getNavigations({ locale: options?.locale ?? "de", per_page: 50, resolve: options?.resolve, }); const entry = items.find( (n) => (n as { _slug?: string; internal?: string })._slug === key || (n as { _slug?: string; internal?: string }).internal === key, ); return entry ?? null; } /** * Navigation anhand des Slugs holen. */ export async function getNavigationBySlug( slug: string, options?: { locale?: string }, ): Promise { const byKey = await getNavigationByKey(slug, options); if (byKey) return byKey; const key = `navigation:slug:${slug}:${options?.locale ?? ""}`; return cached("navigation", key, async () => { const base = getBaseUrl(); const url = new URL( `${base}/api/content/navigation/${encodeURIComponent(slug)}`, ); if (options?.locale) url.searchParams.set("_locale", options.locale); const res = await cmsFetch(url.toString()); if (res.status === 404) return null; if (!res.ok) return null; return (await res.json()) as NavigationEntry; }); } /** Footer (Single Source of Truth: RustyCMS OpenAPI). */ export type FooterEntry = components["schemas"]["footer"]; /** * Footer anhand des Slugs holen (z. B. "default"). */ export async function getFooterBySlug( slug: string, options?: ContentGetOptions, ): Promise { const resolveKey = options?.resolve?.join(",") ?? ""; const key = `footer:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`; return cached("footer", key, async () => { const base = getBaseUrl(); const url = new URL(`${base}/api/content/footer/${encodeURIComponent(slug)}`); if (options?.locale) url.searchParams.set("_locale", options.locale); if (options?.resolve?.length) url.searchParams.set("_resolve", options.resolve.join(",")); const res = await cmsFetch(url.toString()); if (res.status === 404) return null; if (!res.ok) { throw new Error( `RustyCMS get footer failed: ${res.status} for slug "${slug}".`, ); } return (await res.json()) as FooterEntry; }); } /** Post-Eintrag (Single Source of Truth: RustyCMS OpenAPI). */ export type PostEntry = components["schemas"]["post"]; /** Normalisiert Post-Slug für URL (führenden + trailing Slash entfernen). */ function normalizePostSlug(s: string | undefined): string { return (s ?? "").replace(/^\/+|\/+$/g, "").trim(); } /** * Post nach Slug (GET /api/content/post/:slug). */ export async function getPostBySlug( slug: string, options?: ContentGetOptions, ): Promise { const resolveKey = options?.resolve?.join(",") ?? ""; const fieldsKey = options?.fields?.join(",") ?? ""; const depthKey = options?.depth ?? ""; const key = `post:slug:${slug}:${options?.locale ?? ""}:${resolveKey}:${depthKey}:${fieldsKey}`; const fetchFn = async () => { const base = getBaseUrl(); const trySlug = async (s: string): Promise => { const u = new URL(`${base}/api/content/post/${encodeURIComponent(s)}`); if (options?.locale) u.searchParams.set("_locale", options.locale); if (options?.resolve?.length) u.searchParams.set("_resolve", options.resolve.join(",")); 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); return cmsFetch(u.toString()); }; const normSlug = normalizePostSlug(slug); let res = await trySlug(normSlug); if (res.status === 404) { res = await trySlug("/" + normSlug); } if (res.status === 404) { const items = await getPosts(); const match = items.find( (p) => normalizePostSlug(p.slug) === normSlug || normalizePostSlug(p._slug) === normSlug, ); if (match?._slug) { res = await trySlug(match._slug); } } if (res.status === 404) return null; if (!res.ok) { throw new Error( `RustyCMS get post failed: ${res.status} for slug "${slug}".`, ); } return (await res.json()) as PostEntry; }; if (options?.preview || options?.previewDraft) return 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>( collection: string, slug: string, options?: ContentGetOptions, ): Promise { 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 => { 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. */ export async function getPostSlugs(): Promise { const items = await getPosts(); return items.map((p) => normalizePostSlug(p.slug ?? p._slug)).filter(Boolean); } /** Optionen für getPosts (Query-Parameter _sort, _order, _resolve, _depth, _fields). */ export interface GetPostsOptions { _sort?: string; _order?: "asc" | "desc"; /** Referenzen auflösen, z. B. "all" oder ["postImage", "postTag"] */ resolve?: string | string[]; /** Cap für Resolve-Rekursionstiefe (0..=10). */ depth?: number; /** Sparse-Fieldset, z. B. ["headline", "excerpt", "postImage.*", "postTag.name"]. */ fields?: string[]; } /** Alle Post-Einträge (GET /api/content/post). Gecacht pro Request. */ export async function getPosts( options?: GetPostsOptions, ): Promise { const opts = options ?? {}; const resolveVal = Array.isArray(opts.resolve) ? opts.resolve.join(",") : (opts.resolve ?? ""); const fieldsVal = opts.fields?.join(",") ?? ""; const depthVal = opts.depth ?? ""; const key = `post:list:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}:${depthVal}:${fieldsVal}`; return cached("post", key, async () => { const base = getBaseUrl(); const url = new URL(`${base}/api/content/post`); url.searchParams.set("_per_page", "1000"); if (opts._sort) url.searchParams.set("_sort", opts._sort); if (opts._order) url.searchParams.set("_order", opts._order); if (resolveVal) url.searchParams.set("_resolve", resolveVal); if (typeof opts.depth === "number") url.searchParams.set("_depth", String(opts.depth)); if (fieldsVal) url.searchParams.set("_fields", fieldsVal); const res = await cmsFetch(url.toString()); if (!res.ok) throw new Error("RustyCMS list post failed"); const data = (await res.json()) as { items?: PostEntry[] }; return data.items ?? []; }); } /** Tag-Eintrag (z. B. für Blog-Filter). */ export type TagEntry = components["schemas"]["tag"]; /** Alle Tags (GET /api/content/tag). TTL-gecacht. */ export async function getTags(): Promise { return cached("tag", "tag:list:", async () => { const base = getBaseUrl(); const res = await cmsFetch(`${base}/api/content/tag`); if (!res.ok) return []; const data = (await res.json()) as { items?: TagEntry[] }; return data.items ?? []; }); } /** Text-Fragment (z. B. für searchable_text). */ export type TextFragmentEntry = components["schemas"]["text_fragment"]; /** Text-Fragment anhand Slug (GET /api/content/text_fragment/:slug). */ export async function getTextFragmentBySlug( slug: string, options?: { locale?: string }, ): Promise { const key = `text_fragment:slug:${slug}:${options?.locale ?? ""}`; return cached("text_fragment", key, async () => { const base = getBaseUrl(); const url = new URL( `${base}/api/content/text_fragment/${encodeURIComponent(slug)}`, ); if (options?.locale) url.searchParams.set("_locale", options.locale); const res = await cmsFetch(url.toString()); if (res.status === 404) return null; if (!res.ok) { throw new Error( `RustyCMS get text_fragment failed: ${res.status} for slug "${slug}".`, ); } return (await res.json()) as TextFragmentEntry; }); } /** Fullwidth-Banner (z. B. für Top-Banner mit Bild). */ export type TopBannerEntry = components["schemas"]["top_banner"]; /** Top-Banner anhand Slug (GET /api/content/top_banner/:slug). */ export async function getTopBannerBySlug( slug: string, options?: { locale?: string }, ): Promise { const key = `top_banner:slug:${slug}:${options?.locale ?? ""}`; return cached("top_banner", key, async () => { const base = getBaseUrl(); const url = new URL( `${base}/api/content/top_banner/${encodeURIComponent(slug)}`, ); if (options?.locale) url.searchParams.set("_locale", options.locale); const res = await cmsFetch(url.toString()); if (res.status === 404) return null; if (!res.ok) { throw new Error( `RustyCMS get top_banner failed: ${res.status} for slug "${slug}".`, ); } return (await res.json()) as TopBannerEntry; }); } export type FullwidthBannerEntry = components["schemas"]["fullwidth_banner"]; /** Fullwidth-Banner anhand Slug (GET /api/content/fullwidth_banner/:slug). */ export async function getFullwidthBannerBySlug( slug: string, options?: { locale?: string }, ): Promise { const key = `fullwidth_banner:slug:${slug}:${options?.locale ?? ""}`; return cached("fullwidth_banner", key, async () => { const base = getBaseUrl(); const url = new URL( `${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`, ); if (options?.locale) url.searchParams.set("_locale", options.locale); const res = await cmsFetch(url.toString()); if (res.status === 404) return null; if (!res.ok) { throw new Error( `RustyCMS get fullwidth_banner failed: ${res.status} for slug "${slug}".`, ); } return (await res.json()) as FullwidthBannerEntry; }); } /** Translation-Eintrag (UI-Strings für i18n). Collection: translation. */ export type TranslationEntry = { _slug?: string; text: string }; /** Translation anhand Slug (GET /api/content/translation/:slug). */ export async function getTranslationBySlug( slug: string, options?: { locale?: string }, ): Promise { const key = `translation:slug:${slug}:${options?.locale ?? ""}`; return cached("translation", key, async () => { const base = getBaseUrl(); const url = new URL( `${base}/api/content/translation/${encodeURIComponent(slug)}`, ); if (options?.locale) url.searchParams.set("_locale", options.locale); const res = await cmsFetch(url.toString()); if (res.status === 404) return null; if (!res.ok) return null; return (await res.json()) as TranslationEntry; }); } /** Translation-Bundle: ein Objekt mit vielen Keys (String → String). Collection: translation_bundle. */ export type TranslationBundleEntry = { _slug?: string; strings: Record; }; /** * Translation-Bundle anhand Slug (GET /api/content/translation_bundle/:slug). */ export async function getTranslationBundleBySlug( slug: string, options?: { locale?: string }, ): Promise { const key = `translation_bundle:slug:${slug}:${options?.locale ?? ""}`; return cached("translation_bundle", key, async () => { const base = getBaseUrl(); const url = new URL( `${base}/api/content/translation_bundle/${encodeURIComponent(slug)}`, ); url.searchParams.set("_resolve", "all"); if (options?.locale) url.searchParams.set("_locale", options.locale); const res = await cmsFetch(url.toString()); if (res.status === 404) return null; if (!res.ok) return null; return (await res.json()) as TranslationBundleEntry; }); } /** Kalender-Eintrag (calendar) – aus OpenAPI-Spec. */ export type CalendarEntry = components["schemas"]["calendar"]; /** Kalender-Item (calendar_item) – aus OpenAPI-Spec. */ export type CalendarItemEntry = components["schemas"]["calendar_item"]; /** Kalender anhand Slug (GET /api/content/calendar/:slug). */ export async function getCalendarBySlug( slug: string, options?: { locale?: string; resolve?: string }, ): Promise { const key = `calendar:slug:${slug}:${options?.locale ?? ""}:${options?.resolve ?? ""}`; return cached("calendar", key, async () => { const base = getBaseUrl(); const url = new URL( `${base}/api/content/calendar/${encodeURIComponent(slug)}`, ); if (options?.locale) url.searchParams.set("_locale", options.locale); if (options?.resolve) url.searchParams.set("_resolve", options.resolve); const res = await cmsFetch(url.toString()); if (res.status === 404) return null; if (!res.ok) return null; return (await res.json()) as CalendarEntry; }); } /** Einzelnes calendar_item anhand Slug (GET /api/content/calendar_item/:slug). */ export async function getCalendarItemBySlug( slug: string, options?: { locale?: string }, ): Promise { const key = `calendar_item:slug:${slug}:${options?.locale ?? ""}`; return cached("calendar_item", key, async () => { const base = getBaseUrl(); const url = new URL( `${base}/api/content/calendar_item/${encodeURIComponent(slug)}`, ); if (options?.locale) url.searchParams.set("_locale", options.locale); const res = await cmsFetch(url.toString()); if (res.status === 404) return null; if (!res.ok) return null; return (await res.json()) as CalendarItemEntry; }); } /** Alle calendar_item-Einträge (GET /api/content/calendar_item). */ export async function getCalendarItems( options?: { locale?: string }, ): Promise { const key = `calendar_item:list:${options?.locale ?? ""}`; return cached("calendar_item", key, async () => { const base = getBaseUrl(); const url = new URL(`${base}/api/content/calendar_item`); url.searchParams.set("_per_page", "1000"); url.searchParams.set("_resolve", "all"); if (options?.locale) url.searchParams.set("_locale", options.locale); const res = await cmsFetch(url.toString()); if (!res.ok) return []; const data = (await res.json()) as { items?: CalendarItemEntry[] }; return data.items ?? []; }); } /** * Bulk-fetches approved comment counts for many page_ids in one request. * Hits the comment plugin's `/api/comments/counts` endpoint. Returns a map * `{ pageId → count }` with `0` for any id missing in the response. * * The comment-plugin endpoints live behind a different env (`PUBLIC_CMS_URL`) * and may be on a different origin in dev. SSR-side calls always hit it * directly; the cookie sync of the visitor session does NOT happen here * (counts are public anyway). */ export async function getCommentCounts( pageIds: string[], options?: { environment?: string }, ): Promise> { if (pageIds.length === 0) return {}; const base = getBaseUrl(); const url = new URL(`${base}/api/comments/counts`); url.searchParams.set("ids", pageIds.join(",")); if (options?.environment) { url.searchParams.set("_environment", options.environment); } try { const res = await cmsFetch(url.toString()); if (!res.ok) return {}; const data = (await res.json()) as { counts?: Record }; return data.counts ?? {}; } catch { return {}; } }