336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
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<Map<string, string>> {
|
|
const tags = await getTags();
|
|
const m = new Map<string, string>();
|
|
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<string, string>,
|
|
): 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<T>(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<Map<string, string>> {
|
|
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, string>,
|
|
): 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<string, string>,
|
|
): Promise<void> {
|
|
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;
|
|
}
|
|
}
|
|
}
|