Files
windwiderstand/src/lib/cms.ts
T
Peter Meier ef163fb336
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 1m6s
feat(layout): add global announcement banner above nav
Adds AnnouncementBanner component fed by a new top_banner CMS entry
(site-announcement). Extended top_banner schema and TS types with
optional link + linkLabel fields. Rendered inline-markdown text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:56:42 +02:00

943 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Response> {
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<string, unknown>;
components?: { schemas?: Record<string, unknown> };
};
let openApiCache: OpenApiSpec | null = null;
/**
* TTL-Cache pro Collection (ms). Webhooks purgen bei Mutation → hohe TTL unkritisch.
* Override via PUBLIC_CMS_CACHE_TTL_MS_<COLLECTION> falls nötig.
*/
const CACHE_TTL: Record<string, number> = {
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<T> = { value: T; expires: number };
const cache = new Map<string, CacheEntry<unknown>>();
const inflight = new Map<string, Promise<unknown>>();
function ttlFor(collection: string): number {
return CACHE_TTL[collection] ?? CACHE_TTL.default;
}
/**
* Cached fetch mit TTL + in-flight dedup. Key-Format: "<collection>:<op>:<params>".
* Bei Fehler wird nichts gecacht.
*/
async function cached<T>(
collection: string,
key: string,
fn: () => Promise<T>,
): Promise<T> {
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<T> | 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<OpenApiSpec> {
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<PageEntry[]> {
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<PageConfigEntry[]> {
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<PageConfigEntry | null> {
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<string | null> {
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<T = unknown> =
| { status: 200; data: T }
| { status: number; error: string };
/** Antwort von POST /api/content/_batch. */
export type BatchResponse = { results: Record<string, BatchResult> };
/**
* 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.<id>.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<BatchResponse> {
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<T>(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<PageEntry | null> {
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<Response> => {
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<string[]> {
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<PageStub[]> {
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<PostStub[]> {
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<NavigationEntry | null> {
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<NavigationEntry | null> {
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<FooterEntry | null> {
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<PostEntry | null> {
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<Response> => {
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<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. */
export async function getPostSlugs(): Promise<string[]> {
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<PostEntry[]> {
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<TagEntry[]> {
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<TextFragmentEntry | null> {
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<TopBannerEntry | null> {
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<FullwidthBannerEntry | null> {
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<TranslationEntry | null> {
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<string, string>;
};
/**
* Translation-Bundle anhand Slug (GET /api/content/translation_bundle/:slug).
*/
export async function getTranslationBundleBySlug(
slug: string,
options?: { locale?: string },
): Promise<TranslationBundleEntry | null> {
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<CalendarEntry | null> {
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<CalendarItemEntry | null> {
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<CalendarItemEntry[]> {
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<Record<string, number>> {
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<string, number> };
return data.counts ?? {};
} catch {
return {};
}
}