import type { PostEntry } from "./cms"; import { getPosts, getPostBySlug, getTags, getTextFragmentBySlug } from "./cms"; import type { RowContentLayout } from "./block-types"; /** rusty-image nur dynamisch importieren, damit client-seitige Importe (z. B. PostOverviewBlock) nicht node:crypto in den Browser ziehen. */ /** Slug → Anzeigename (name) aus der Tag-API. Für Auflösung von post.postTag in Lesbarkeit. */ export async function getTagsMap(): Promise> { const tags = await getTags(); const m = new Map(); for (const t of tags) { const slug = (t as { _slug?: string })._slug ?? (t as { slug?: string }).slug ?? ""; if (slug) m.set(slug, (t as { name?: string }).name ?? slug); } return m; } /** Setzt post.postTag auf { _slug, name }[]; bei reinen Slug-Strings wird name aus slugToName geholt. */ export function resolvePostTagsInPost( post: PostEntry, slugToName: Map, ): void { const raw = post.postTag ?? []; if (!Array.isArray(raw)) return; (post as { postTag?: { _slug: string; name: string }[] }).postTag = raw.map( (t) => { if (typeof t === "string") { return { _slug: t, name: slugToName.get(t) ?? t }; } const o = t as { _slug?: string; name?: string }; const slug = o._slug ?? o?.name ?? ""; return { _slug: slug, name: o?.name ?? slugToName.get(slug) ?? slug }; }, ); } const POSTS_PER_PAGE = 10; export function getPostsPerPage(): number { return POSTS_PER_PAGE; } /** Sortiert Posts nach Datum (neueste zuerst). */ export function sortPostsByDate(posts: PostEntry[]): PostEntry[] { return [...posts].sort((a, b) => { const da = a.created ?? a.date ?? ""; const db = b.created ?? b.date ?? ""; if (!da) return 1; if (!db) return -1; return new Date(db).getTime() - new Date(da).getTime(); }); } /** Filtert Posts nach Tag-Slug (post.postTag enthält den Slug). */ export function filterPostsByTag( posts: PostEntry[], tagSlug: string | null, ): PostEntry[] { if (!tagSlug) return posts; return posts.filter((p) => { const tags = p.postTag ?? []; return tags.some((t) => { const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug; return slug === tagSlug; }); }); } /** URL aus post.postImage (RustyCMS img mit src, oder legacy file.url). */ export function getPostImageUrl( postImage: PostEntry["postImage"], ): string | null { if (!postImage || typeof postImage === "string") return null; const obj = postImage as { src?: string; file?: { url?: string }; fields?: { file?: { url?: string } }; }; const url = obj?.src ?? obj?.file?.url ?? obj?.fields?.file?.url; return typeof url === "string" ? url.startsWith("//") ? `https:${url}` : url : null; } /** Filtert Posts: behält nur solche, die mindestens einen der Tag-Slugs haben. */ export function filterPostsByTagSlugs( posts: PostEntry[], tagSlugs: string[], ): PostEntry[] { if (!tagSlugs?.length) return posts; return posts.filter((p) => { const tags = p.postTag ?? []; return tags.some((t) => { const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug; return tagSlugs.includes(slug ?? ""); }); }); } export function getTotalPages(count: number, perPage: number): number { return Math.max(1, Math.ceil(count / perPage)); } export function paginate(items: T[], page: number, perPage: number): T[] { const start = (page - 1) * perPage; return items.slice(start, start + perPage); } const POST_BASE = "/post"; /** Post-URL: slug ist die URL; führender Slash entfernt. */ export function postHref(post: PostEntry): string { const slug = post.slug ?? post._slug ?? ""; const norm = (slug ?? "").replace(/^\//, "").trim(); return norm ? `${POST_BASE}/${encodeURIComponent(norm)}` : POST_BASE; } export function formatPostDate(value: string | undefined): string { if (!value) return ""; try { const d = new Date(value); return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString("de-DE", { year: "numeric", month: "short", day: "numeric", }); } catch { return ""; } } /** Prüft, ob ein Block ein Post-Overview-Block ist (mit _type "post_overview"). */ function isPostOverviewBlock(item: unknown): item is { _type?: string; allPosts?: boolean; posts?: unknown[]; filterByTag?: unknown[]; numberItems?: number; postsResolved?: unknown[]; } { return ( typeof item === "object" && item !== null && (item as { _type?: string })._type === "post_overview" ); } /** * Lädt Posts für alle post_overview-Blöcke im Layout und setzt postsResolved. * Nutzt die globale Tag-API (getTagsMap()) für Slug → Anzeigename. * Gibt die tagsMap zurück (z. B. für resolvePostTagsInPost auf [slug].astro). */ export async function resolvePostOverviewBlocks( layout: RowContentLayout | null | undefined, ): Promise> { const tagsMap = await getTagsMap(); if (!layout) return tagsMap; const rows = [ layout.row1Content ?? [], layout.row2Content ?? [], layout.row3Content ?? [], ]; let allPosts: PostEntry[] | null = null; for (const content of rows) { if (!Array.isArray(content)) continue; for (const item of content) { if (!isPostOverviewBlock(item)) continue; if (item.allPosts) { if (allPosts === null) allPosts = await getPosts({ _sort: "created", _order: "desc", resolve: "all", }); let list = sortPostsByDate(allPosts); const rawFilter = item.filterByTag ?? []; const tagSlugs = Array.isArray(rawFilter) ? rawFilter .map((t) => typeof t === "string" ? t : ((t as { _slug?: string })?._slug ?? ""), ) .filter(Boolean) : []; if (tagSlugs.length) list = filterPostsByTagSlugs(list, tagSlugs); const limit = typeof item.numberItems === "number" ? item.numberItems : 9999; item.postsResolved = list.slice(0, limit); } else if (Array.isArray(item.posts) && item.posts.length > 0) { const first = item.posts[0]; const isResolved = typeof first === "object" && first !== null && "_slug" in first && ("headline" in first || "linkName" in first); if (isResolved) { item.postsResolved = item.posts as PostEntry[]; } else { const slugs = item.posts .map((p) => typeof p === "string" ? p : ((p as { _slug?: string })?._slug ?? ""), ) .filter(Boolean); const limit = typeof item.numberItems === "number" ? item.numberItems : 9999; const resolved: PostEntry[] = []; for (const slug of slugs.slice(0, limit)) { const post = await getPostBySlug(slug, { locale: "de", resolve: ["all"], }); if (post) resolved.push(post); } item.postsResolved = resolved; } } if (Array.isArray(item.postsResolved)) { const posts = item.postsResolved as PostEntry[]; if (posts.length > 0) { for (const post of posts) resolvePostTagsInPost(post, tagsMap); } for (const post of item.postsResolved as PostEntry[]) { const raw = getPostImageUrl(post.postImage); if (raw && import.meta.env.SSR) { try { const { ensureTransformedImage } = await import("./rusty-image"); const url = await ensureTransformedImage(raw, { width: 150, height: 200, fit: "cover", format: "webp", }); ( post as PostEntry & { _resolvedImageUrl?: string } )._resolvedImageUrl = url; } catch { // Bild optional } } } } } } return tagsMap; } function isSearchableTextBlock( item: unknown, ): item is { _type?: string; textFragments?: unknown[] } { return ( typeof item === "object" && item !== null && (item as { _type?: string })._type === "searchable_text" ); } /** Prüft, ob ein Fragment bereits aufgelöst ist (hat title oder text). */ function isResolvedFragment(f: unknown): boolean { if (typeof f !== "object" || f === null) return false; const o = f as { title?: string; text?: string }; return "title" in o || "text" in o; } /** * Normalisiert tags eines Fragments zu Anzeigenamen (string[]). * Nutzt slugToName für Slug → Name; Objekte mit name/_slug werden unterstützt. */ function resolveFragmentTags( tagsRaw: unknown[] | undefined, slugToName: Map, ): string[] { if (!Array.isArray(tagsRaw)) return []; return tagsRaw .map((t) => { if (typeof t === "string") return slugToName.get(t) ?? t; const o = t as { _slug?: string; name?: string }; const slug = o._slug ?? ""; return (o.name ?? slugToName.get(slug) ?? slug).trim(); }) .filter(Boolean); } /** * Lädt für alle searchable_text-Blöcke im Layout die textFragments nach * (wenn sie als Slugs/Refs kommen), setzt die aufgelösten Einträge und * löst Tag-Slugs/Refs in Anzeigenamen auf. * @param slugToName Optionale Map Slug → Anzeigename (z. B. von resolvePostOverviewBlocks); sonst getTagsMap(). */ export async function resolveSearchableTextBlocks( layout: RowContentLayout | null | undefined, slugToName?: Map, ): Promise { if (!layout) return; const tagsMap = slugToName ?? (await getTagsMap()); const rows = [ layout.row1Content ?? [], layout.row2Content ?? [], layout.row3Content ?? [], ]; for (const content of rows) { if (!Array.isArray(content)) continue; for (const item of content) { if (!isSearchableTextBlock(item)) continue; const raw = item.textFragments ?? []; if (!Array.isArray(raw) || raw.length === 0) continue; const needsResolve = raw.some((f) => !isResolvedFragment(f)); if (!needsResolve) continue; const resolved: unknown[] = []; for (const f of raw) { const slug = typeof f === "string" ? f : ((f as { _slug?: string })?._slug ?? ""); if (!slug) { if (isResolvedFragment(f)) resolved.push(f); continue; } const frag = await getTextFragmentBySlug(slug, { locale: "de" }); if (frag) { const fragObj = frag as { tags?: unknown[] }; const tagNames = resolveFragmentTags(fragObj.tags, tagsMap); resolved.push({ ...fragObj, tags: tagNames }); } } (item as { textFragments?: unknown[] }).textFragments = resolved; } } }