Enhance project structure and functionality by adding new components and integrations. Updated .gitignore to exclude history and transformed images. Integrated Svelte, sitemap, and icon support in astro.config.mjs. Added multiple new components including BlogOverview, ContentRows, Footer, Header, and various block components for improved content management. Updated package.json with new dependencies and modified scripts for build and development processes.
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user