Files
windwiderstand/src/lib/cms.ts
T
Peter Meier 62d25202a6
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 59s
feat(deadline-banner): auto mode pulls all calendar_items globally
Auto-mode deadline_banner now resolves to the next future event across
all calendar_items in the CMS instead of requiring a manually-curated
items[] pool. Server load hooks (+page.server.ts for /, /[...slug],
/post/[slug]) call resolveDeadlineBannerBlocks which fetches the
global calendar_item list once and attaches it as items[] when mode
is "auto" and items[] is not already resolved. Component logic
unchanged — it still picks the soonest future entry.

Also:
- cms.ts: getCalendarItems() bulk list fetcher
- PostOverviewBlock.svelte: design defaults to "cards" when value is
  empty string (not just null/undefined) so existing entries with
  design: "" render

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 11:02:50 +02:00

686 lines
23 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(/\/$/, "");
};
/** 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> {
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) return openApiCache;
const base = getBaseUrl();
const res = await fetch(`${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 fetch(`${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 fetch(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 fetch(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[];
};
/** 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 key = `page:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
return cached("page", key, 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(","));
return fetch(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;
});
}
/** 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);
}
/** 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 fetch(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 fetch(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 fetch(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 key = `post:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
return cached("post", key, 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(","));
return fetch(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;
});
}
/** 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). */
export interface GetPostsOptions {
_sort?: string;
_order?: "asc" | "desc";
/** Referenzen auflösen, z. B. "all" oder ["postImage", "postTag"] */
resolve?: string | 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 key = `post:list:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}`;
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);
const res = await fetch(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 fetch(`${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 fetch(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 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 fetch(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 fetch(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 fetch(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 fetch(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 fetch(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");
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
if (!res.ok) return [];
const data = (await res.json()) as { items?: CalendarItemEntry[] };
return data.items ?? [];
});
}