Initial SvelteKit frontend port of windwiderstand.de
Deploy / verify (push) Failing after 32s
Deploy / deploy (push) Has been skipped

Full parity with Astro site: content rows, post/tag routes, pagination,
event badges + OSM map, comments, Live-Search via /api/search-index,
CMS image proxy, RSS, sitemap.

Deploy: Dockerfile + docker-compose.prod.yml + Gitea Actions workflow
to build + push to git.pm86.de registry and ssh-deploy to Contabo.
This commit is contained in:
Peter Meier
2026-04-17 22:01:30 +02:00
parent 852cef0980
commit 2fef91a548
137 changed files with 28585 additions and 0 deletions
+672
View File
@@ -0,0 +1,672 @@
/**
* 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 Slash entfernen (CMS kann "slug": "/about" liefern). */
function normalizePageSlug(s: string | undefined): string {
return (s ?? "").replace(/^\//, "").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());
};
let res = await trySlug(slug);
if (res.status === 404 && !slug.startsWith("/")) {
res = await trySlug("/" + slug);
}
if (res.status === 404) {
const items = await getPages();
const norm = normalizePageSlug(slug);
const match = items.find(
(p) =>
normalizePageSlug(p.slug) === norm ||
normalizePageSlug(p._slug) === norm ||
p._slug === slug ||
p.slug === slug,
);
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 Slash entfernen). */
function normalizePostSlug(s: string | undefined): string {
return (s ?? "").replace(/^\//, "").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());
};
let res = await trySlug(slug);
if (res.status === 404 && !slug.startsWith("/")) {
res = await trySlug("/" + slug);
}
if (res.status === 404) {
const items = await getPosts();
const norm = normalizePostSlug(slug);
const match = items.find(
(p) =>
normalizePostSlug(p.slug) === norm ||
normalizePostSlug(p._slug) === norm ||
p._slug === slug ||
p.slug === slug,
);
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;
});
}