ef163fb336
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>
943 lines
34 KiB
TypeScript
943 lines
34 KiB
TypeScript
/**
|
||
* 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 {};
|
||
}
|
||
}
|