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,151 @@
|
||||
/**
|
||||
* Layout für Content-Blöcke (z. B. Markdown): Grid-Spalten (1–12) und Abstand.
|
||||
* Entspricht component_layout im CMS (desktop/tablet/mobile = Breite in 12tel, spaceBottom = rem).
|
||||
*/
|
||||
|
||||
export type BlockLayout = {
|
||||
/** Breite auf Desktop (1–12), z. B. "8" = 8/12. */
|
||||
desktop?:
|
||||
| "1"
|
||||
| "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "7"
|
||||
| "8"
|
||||
| "9"
|
||||
| "10"
|
||||
| "11"
|
||||
| "12";
|
||||
/** Breite auf Tablet (1–12). */
|
||||
tablet?:
|
||||
| "1"
|
||||
| "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "7"
|
||||
| "8"
|
||||
| "9"
|
||||
| "10"
|
||||
| "11"
|
||||
| "12";
|
||||
/** Breite auf Mobile (1–12), Default 12. */
|
||||
mobile?:
|
||||
| "1"
|
||||
| "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "7"
|
||||
| "8"
|
||||
| "9"
|
||||
| "10"
|
||||
| "11"
|
||||
| "12";
|
||||
/** Abstand nach unten (rem): 0, 0.5, 1, 1.5, 2. */
|
||||
spaceBottom?: 0 | 0.5 | 1 | 1.5 | 2;
|
||||
/** true = Block fullwidth aus dem Grid ausbrechen (viewport-breit). */
|
||||
breakout?: boolean;
|
||||
};
|
||||
|
||||
const COL_SPAN: Record<string, string> = {
|
||||
"1": "col-span-1",
|
||||
"2": "col-span-2",
|
||||
"3": "col-span-3",
|
||||
"4": "col-span-4",
|
||||
"5": "col-span-5",
|
||||
"6": "col-span-6",
|
||||
"7": "col-span-7",
|
||||
"8": "col-span-8",
|
||||
"9": "col-span-9",
|
||||
"10": "col-span-10",
|
||||
"11": "col-span-11",
|
||||
"12": "col-span-12",
|
||||
};
|
||||
|
||||
const MD_COL_SPAN: Record<string, string> = {
|
||||
"1": "md:col-span-1",
|
||||
"2": "md:col-span-2",
|
||||
"3": "md:col-span-3",
|
||||
"4": "md:col-span-4",
|
||||
"5": "md:col-span-5",
|
||||
"6": "md:col-span-6",
|
||||
"7": "md:col-span-7",
|
||||
"8": "md:col-span-8",
|
||||
"9": "md:col-span-9",
|
||||
"10": "md:col-span-10",
|
||||
"11": "md:col-span-11",
|
||||
"12": "md:col-span-12",
|
||||
};
|
||||
|
||||
const LG_COL_SPAN: Record<string, string> = {
|
||||
"1": "lg:col-span-1",
|
||||
"2": "lg:col-span-2",
|
||||
"3": "lg:col-span-3",
|
||||
"4": "lg:col-span-4",
|
||||
"5": "lg:col-span-5",
|
||||
"6": "lg:col-span-6",
|
||||
"7": "lg:col-span-7",
|
||||
"8": "lg:col-span-8",
|
||||
"9": "lg:col-span-9",
|
||||
"10": "lg:col-span-10",
|
||||
"11": "lg:col-span-11",
|
||||
"12": "lg:col-span-12",
|
||||
};
|
||||
|
||||
const SPACE_BOTTOM: Record<number, string> = {
|
||||
0: "mb-0",
|
||||
0.5: "mb-2", // 0.5rem
|
||||
1: "mb-4", // 1rem
|
||||
1.5: "mb-6", // 1.5rem
|
||||
2: "mb-8", // 2rem
|
||||
};
|
||||
|
||||
/**
|
||||
* Gibt Tailwind-Klassen für das Block-Layout zurück (12-Spalten-Grid + Abstand).
|
||||
* Parent muss grid grid-cols-12 haben.
|
||||
*/
|
||||
/**
|
||||
* Gibt Tailwind-Klassen für den Block-Inhalt zurück.
|
||||
* Bei breakout: true nur w-full + Abstand (Spalten/Wrapper kommen von außen).
|
||||
*/
|
||||
export function getBlockLayoutClasses(
|
||||
layout: BlockLayout | undefined | null,
|
||||
): string {
|
||||
if (!layout || typeof layout !== "object") {
|
||||
return "col-span-12 mb-4";
|
||||
}
|
||||
const breakout = layout.breakout === true;
|
||||
const sb =
|
||||
typeof layout.spaceBottom === "number"
|
||||
? layout.spaceBottom
|
||||
: Number(layout.spaceBottom);
|
||||
const spaceClass = SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
|
||||
|
||||
if (breakout) {
|
||||
return `w-full ${spaceClass}`;
|
||||
}
|
||||
|
||||
const mobile = String(layout.mobile ?? "12");
|
||||
const tablet = layout.tablet != null ? String(layout.tablet) : undefined;
|
||||
const desktop = layout.desktop != null ? String(layout.desktop) : undefined;
|
||||
|
||||
const parts: string[] = [
|
||||
COL_SPAN[mobile] ?? "col-span-12",
|
||||
tablet ? (MD_COL_SPAN[tablet] ?? "") : "",
|
||||
desktop ? (LG_COL_SPAN[desktop] ?? "") : "",
|
||||
spaceClass,
|
||||
];
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/** Prüft, ob das Layout breakout (fullwidth) haben soll. */
|
||||
export function isBlockLayoutBreakout(
|
||||
layout: BlockLayout | undefined | null,
|
||||
): boolean {
|
||||
return !!(layout && typeof layout === "object" && layout.breakout === true);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Gemeinsame Typen für Content-Blöcke (Rows, Markdown, …).
|
||||
* Wird von Astro-Seiten und Svelte-Komponenten genutzt.
|
||||
*/
|
||||
|
||||
import type { BlockLayout } from "./block-layout";
|
||||
|
||||
/** Aufgelöster Block vom CMS (mit _type, _slug + typ-spezifische Felder). */
|
||||
export type ResolvedBlock = {
|
||||
_type?: string;
|
||||
_slug?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/** Gemeinsames Row-Layout (page, post, footer erben content_layout). */
|
||||
export interface RowContentLayout {
|
||||
row1Content?: unknown[];
|
||||
row1JustifyContent?: string;
|
||||
row1AlignItems?: string;
|
||||
row2Content?: unknown[];
|
||||
row2JustifyContent?: string;
|
||||
row2AlignItems?: string;
|
||||
row3Content?: unknown[];
|
||||
row3JustifyContent?: string;
|
||||
row3AlignItems?: string;
|
||||
}
|
||||
|
||||
/** Resolved Markdown-Block vom CMS (_type: "markdown"). */
|
||||
export interface MarkdownBlockData {
|
||||
_type?: "markdown";
|
||||
_slug?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
alignment?: "left" | "center" | "right";
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Headline (_type: "headline"). */
|
||||
export interface HeadlineBlockData {
|
||||
_type?: "headline";
|
||||
_slug?: string;
|
||||
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
text?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** HTML (_type: "html"). */
|
||||
export interface HtmlBlockData {
|
||||
_type?: "html";
|
||||
_slug?: string;
|
||||
html?: string;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Iframe (_type: "iframe"). */
|
||||
export interface IframeBlockData {
|
||||
_type?: "iframe";
|
||||
_slug?: string;
|
||||
iframe?: string;
|
||||
content?: string;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Image (_type: "image"). image oder img: Slug, URL-String oder aufgelöstes Objekt (src/description oder file.url). */
|
||||
export interface ImageBlockData {
|
||||
_type?: "image";
|
||||
_slug?: string;
|
||||
image?:
|
||||
| string
|
||||
| { _type?: "img"; _slug?: string; src?: string; description?: string; file?: { url?: string }; title?: string };
|
||||
/** Alternative zu image (manche CMS-Responses liefern img). */
|
||||
img?:
|
||||
| string
|
||||
| { _type?: "img"; _slug?: string; src?: string; description?: string; file?: { url?: string }; title?: string };
|
||||
/** Nach resolveContentImages: lokaler Pfad (public) zum transformierten Bild. */
|
||||
resolvedImageSrc?: string;
|
||||
caption?: string;
|
||||
aspectRatio?: number;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Eintrag in einer image_gallery (img mit src/description). */
|
||||
export interface ImageGalleryImage {
|
||||
_type?: "img";
|
||||
_slug?: string;
|
||||
src?: string;
|
||||
description?: string;
|
||||
file?: { url?: string };
|
||||
}
|
||||
|
||||
/** Bildergalerie (_type: "image_gallery"). images: Array von img-Objekten (src, description). */
|
||||
export interface ImageGalleryBlockData {
|
||||
_type?: "image_gallery";
|
||||
_slug?: string;
|
||||
/** Optionaler Einleitungstext (Markdown). */
|
||||
description?: string;
|
||||
images?: ImageGalleryImage[];
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** YouTube-Video (_type: "youtube_video"). */
|
||||
export interface YoutubeVideoBlockData {
|
||||
_type?: "youtube_video";
|
||||
_slug?: string;
|
||||
youtubeId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
params?: string;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Zitat (_type: "quote"). */
|
||||
export interface QuoteBlockData {
|
||||
_type?: "quote";
|
||||
_slug?: string;
|
||||
quote?: string;
|
||||
author?: string;
|
||||
variant?: "left" | "right";
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Link-Liste (_type: "link_list"). links können Slugs oder aufgelöste link-Objekte sein. */
|
||||
export interface LinkListBlockData {
|
||||
_type?: "link_list";
|
||||
_slug?: string;
|
||||
headline?: string;
|
||||
links?: Array<string | { url?: string; linkName?: string; newTab?: boolean }>;
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
|
||||
/** Post-Übersicht (_type: "post_overview"). Nach resolvePostOverviewBlocks: postsResolved gesetzt. */
|
||||
export interface PostOverviewBlockData {
|
||||
_type?: "post_overview";
|
||||
_slug?: string;
|
||||
headline?: string;
|
||||
/** Einleitungstext (Markdown). */
|
||||
text?: string;
|
||||
/** true = alle Posts laden; false = posts (Slugs/Objekte) aus dem Block nutzen. */
|
||||
allPosts?: boolean;
|
||||
/** Nur wenn allPosts false: feste Post-Liste (Slugs oder aufgelöste Einträge). */
|
||||
posts?: unknown[];
|
||||
/** Tag-Slugs, nach denen gefiltert wird (nur bei allPosts true). */
|
||||
filterByTag?: string[];
|
||||
/** Max. Anzahl Einträge. */
|
||||
numberItems?: number;
|
||||
/** "list" | "cards". */
|
||||
design?: "list" | "cards";
|
||||
layout?: BlockLayout;
|
||||
/** Nach resolvePostOverviewBlocks: geladene/gefilterte Posts (PostEntry[]). */
|
||||
postsResolved?: unknown[];
|
||||
}
|
||||
|
||||
/** Tag-Referenz (API liefert oft { _slug, _type, name }). */
|
||||
export type SearchableTextTagRef = string | { _slug?: string; name?: string };
|
||||
|
||||
/** Ein Text-Fragment (aus text_fragment), für SearchableTextBlock. */
|
||||
export interface SearchableTextFragment {
|
||||
_slug?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
tags?: SearchableTextTagRef[];
|
||||
}
|
||||
|
||||
/** Durchsuchbarer Text (_type: "searchable_text"). textFragments können Slugs oder aufgelöste Objekte sein. */
|
||||
export interface SearchableTextBlockData {
|
||||
_type?: "searchable_text";
|
||||
_slug?: string;
|
||||
/** Titel der Suchkomponente. */
|
||||
title?: string;
|
||||
/** Einleitung (Markdown). */
|
||||
description?: string;
|
||||
/** Slugs oder aufgelöste text_fragment-Einträge. */
|
||||
textFragments?: (string | SearchableTextFragment)[];
|
||||
layout?: BlockLayout;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7443
-71
File diff suppressed because it is too large
Load Diff
+457
-15
@@ -2,6 +2,7 @@
|
||||
* 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: `yarn generate:api-types` (RustyCMS muss laufen).
|
||||
*/
|
||||
@@ -10,7 +11,9 @@ import type { components, operations } from "./cms-api.generated";
|
||||
|
||||
const getBaseUrl = (): string => {
|
||||
const url = import.meta.env.PUBLIC_CMS_URL;
|
||||
const base = (typeof url === "string" && url.trim() ? url : "http://localhost:3000").replace(/\/$/, "");
|
||||
const base = (
|
||||
typeof url === "string" && url.trim() ? url : "http://localhost:3000"
|
||||
).replace(/\/$/, "");
|
||||
return base;
|
||||
};
|
||||
|
||||
@@ -18,7 +21,16 @@ const getBaseUrl = (): string => {
|
||||
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"];
|
||||
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;
|
||||
@@ -29,6 +41,9 @@ export type OpenApiSpec = {
|
||||
|
||||
let openApiCache: OpenApiSpec | null = null;
|
||||
|
||||
/** Einfacher Request-Cache: gleiche Aufrufe (pro Prozess) nur einmal ausführen. Reduziert doppelte CMS-Requests bei Layout + Page. */
|
||||
const listCache = new Map<string, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* Lädt die OpenAPI-Spezifikation von GET /api-docs/openapi.json.
|
||||
* Wird gecacht (einmal pro Request/Build).
|
||||
@@ -39,7 +54,7 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||
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}?`
|
||||
`RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`,
|
||||
);
|
||||
}
|
||||
const spec = (await res.json()) as OpenApiSpec;
|
||||
@@ -48,37 +63,464 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Page-Einträge (GET /api/content/page).
|
||||
* Alle Page-Einträge (GET /api/content/page). Gecacht pro Prozess.
|
||||
*/
|
||||
export async function getPages(): Promise<PageEntry[]> {
|
||||
const key = "getPages";
|
||||
let p = listCache.get(key) as Promise<PageEntry[]> | undefined;
|
||||
if (!p) {
|
||||
p = (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 ?? [];
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle page_config-Einträge (GET /api/content/page_config).
|
||||
* Wie in windwiderstand: eine Config (z. B. slug "default") mit u. a. homePage.
|
||||
*/
|
||||
export async function getPageConfigs(options?: {
|
||||
locale?: string;
|
||||
per_page?: number;
|
||||
}): Promise<PageConfigEntry[]> {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/page`);
|
||||
const url = new URL(`${base}/api/content/page_config`);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
url.searchParams.set("_per_page", String(options?.per_page ?? 50));
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`
|
||||
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as PageListResponse;
|
||||
return data.items ?? [];
|
||||
const data = (await res.json()) as PageConfigListResponse;
|
||||
return (data.items ?? []) as PageConfigEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-Config anhand Slug (z. B. "default"). Gecacht pro Prozess (Key: slug + options).
|
||||
*/
|
||||
export async function getPageConfigBySlug(
|
||||
slug: string,
|
||||
options?: { locale?: string; resolve?: string },
|
||||
): Promise<PageConfigEntry | null> {
|
||||
const opts = options ?? {};
|
||||
const key = `getPageConfigBySlug:${slug}:${opts.locale ?? ""}:${opts.resolve ?? ""}`;
|
||||
let p = listCache.get(key) as Promise<PageConfigEntry | null> | undefined;
|
||||
if (!p) {
|
||||
p = (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;
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startseiten-Slug aus page_config (wie windwiderstand).
|
||||
* Versucht zuerst page-config-default, dann default, sonst erstes Config-Item aus der Liste.
|
||||
* Liefert homePage als Slug (String oder ref._slug), sonst null → Fallback auf Konstante.
|
||||
*/
|
||||
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).
|
||||
* Die API erwartet oft _slug (z. B. "page-about"); die URL nutzt "about".
|
||||
* Versucht: slug, "/" + slug, dann Lookup in der Liste und Abruf per _slug.
|
||||
*/
|
||||
export async function getPageBySlug(slug: string): Promise<PageEntry | null> {
|
||||
export async function getPageBySlug(
|
||||
slug: string,
|
||||
options?: ContentGetOptions,
|
||||
): Promise<PageEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const res = await fetch(`${base}/api/content/page/${encodeURIComponent(slug)}`);
|
||||
const trySlug = (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}".`);
|
||||
throw new Error(
|
||||
`RustyCMS get page failed: ${res.status} for slug "${slug}".`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as PageEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Slugs für Page (für getStaticPaths / generate).
|
||||
*/
|
||||
/** 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) => p._slug ?? p.slug ?? "").filter(Boolean);
|
||||
return items.map((p) => normalizePageSlug(p.slug ?? p._slug)).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Navigation (Single Source of Truth: RustyCMS OpenAPI). Nach Schema-Änderung: `yarn generate:api-types` mit laufendem RustyCMS. */
|
||||
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 Prozess (Key aus options).
|
||||
*/
|
||||
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 = `getNavigations:${opts.locale ?? ""}:${opts.per_page ?? 50}:${opts.resolve ?? ""}`;
|
||||
let p = listCache.get(key) as
|
||||
| Promise<{
|
||||
items: NavigationEntry[];
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}>
|
||||
| undefined;
|
||||
if (!p) {
|
||||
p = (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,
|
||||
};
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Keys für Navigation wie bei windwiderstand (CF_Navigation_Keys). */
|
||||
export const NavigationKeys = {
|
||||
header: "navigation-header",
|
||||
socialMedia: "navigation-social-media",
|
||||
footer: "navigation-footer",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Navigation anhand Key/Slug aus der Liste holen (wie getNavigationByKey in windwiderstand).
|
||||
* Mit options.resolve (z. B. "links") werden links aufgelöst (Link-Einträge mit url, linkName).
|
||||
*/
|
||||
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 (z. B. "header").
|
||||
* Zuerst Versuch über Listen-Endpunkt (getNavigationByKey), falls gewünscht nur Liste:
|
||||
* GET /api/content/navigation?_page=1&_per_page=50&_locale=de, dann Eintrag mit _slug/internal finden.
|
||||
* Optional: GET /api/content/navigation/:slug falls vom Backend unterstützt.
|
||||
*/
|
||||
export async function getNavigationBySlug(
|
||||
slug: string,
|
||||
options?: { locale?: string },
|
||||
): Promise<NavigationEntry | null> {
|
||||
const byKey = await getNavigationByKey(slug, options);
|
||||
if (byKey) return byKey;
|
||||
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").
|
||||
* content/de/footer/default.json5 → slug "default", locale "de".
|
||||
*/
|
||||
export async function getFooterBySlug(
|
||||
slug: string,
|
||||
options?: ContentGetOptions,
|
||||
): Promise<FooterEntry | null> {
|
||||
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"];
|
||||
|
||||
/**
|
||||
* Post anhand des Slugs holen (GET /api/content/post/:slug).
|
||||
*/
|
||||
/** 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).
|
||||
* Die API erwartet oft _slug; die URL nutzt post.slug. Bei 404: Liste laden und per _slug nachladen.
|
||||
*/
|
||||
export async function getPostBySlug(
|
||||
slug: string,
|
||||
options?: ContentGetOptions,
|
||||
): Promise<PostEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const trySlug = (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 Prozess (Key aus options). */
|
||||
export async function getPosts(
|
||||
options?: GetPostsOptions,
|
||||
): Promise<PostEntry[]> {
|
||||
const opts = options ?? {};
|
||||
const resolveVal = Array.isArray(opts.resolve)
|
||||
? opts.resolve.join(",")
|
||||
: (opts.resolve ?? "");
|
||||
const key = `getPosts:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}`;
|
||||
let p = listCache.get(key) as Promise<PostEntry[]> | undefined;
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(`${base}/api/content/post`);
|
||||
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 ?? [];
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Tag-Eintrag (z. B. für Blog-Filter). */
|
||||
export type TagEntry = components["schemas"]["tag"];
|
||||
|
||||
/** Alle Tags (GET /api/content/tag). Gecacht pro Prozess. */
|
||||
export async function getTags(): Promise<TagEntry[]> {
|
||||
const key = "getTags";
|
||||
let p = listCache.get(key) as Promise<TagEntry[]> | undefined;
|
||||
if (!p) {
|
||||
p = (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 ?? [];
|
||||
})();
|
||||
listCache.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 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 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 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Zentrale Konstanten für die Anwendung.
|
||||
* URLs, CMS-Resolve-Arrays und Fallback-Werte.
|
||||
*/
|
||||
|
||||
/** Platzhalter-Bild für og:image / twitter:image, wenn weder Page/Post-Bild noch Logo gesetzt ist. */
|
||||
export const DEFAULT_SOCIAL_IMAGE_URL =
|
||||
"https://images.ctfassets.net/xjxq6v7l1pfe/43yZHpF9OCnrBlEWHJSZMK/1ef20b265ea1e4e55317812ee5ae6b4c/simple-green-tree-illustrated-watercolor-with-gentle-shading-great-rural-scenery-farm-backdrops-naturethemed-designs-landsca.jpg";
|
||||
|
||||
/** RustyImage-Parameter für og:image / twitter:image (immer über unser Transform). */
|
||||
export const SOCIAL_IMAGE_TRANSFORM = {
|
||||
width: 200,
|
||||
height: 200,
|
||||
fit: "cover" as const,
|
||||
format: "webp" as const,
|
||||
};
|
||||
|
||||
/** CMS Content-Rows für resolve (Footer, Pages, Posts). */
|
||||
export const ROW_RESOLVE: string[] = [
|
||||
"row1Content",
|
||||
"row2Content",
|
||||
"row3Content",
|
||||
];
|
||||
|
||||
/** Resolve für Page-Requests: "all" = alle Referenzen inkl. verschachtelt (z. B. image_gallery.images). */
|
||||
export const PAGE_RESOLVE = ["all"];
|
||||
|
||||
/** Resolve für Post-Requests (Rows + Post-Bild, Tags). */
|
||||
export const POST_RESOLVE = [...ROW_RESOLVE, "postImage", "postTag"];
|
||||
|
||||
/** Fallback-Slug für die Startseite, wenn page_config keine homePage liefert. */
|
||||
export const DEFAULT_HOME_PAGE_SLUG = "page-home";
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Iconify Offline: MDI-Collection für Server und Client registrieren.
|
||||
* Muss an zwei Stellen importiert werden (Side-Effect-Import), damit
|
||||
* <Icon icon="mdi:…" /> überall gleich rendert (kein Hydration-Mismatch):
|
||||
* 1. Layout.astro (--- Block) → läuft beim SSR
|
||||
* 2. Header.svelte (oder andere frühe client:load-Komponente) → läuft im Browser
|
||||
* Danach können alle Svelte-Komponenten Icon ohne API nutzen.
|
||||
*/
|
||||
import { addCollection } from "@iconify/svelte";
|
||||
import { icons as mdiIcons } from "@iconify-json/mdi";
|
||||
|
||||
addCollection(mdiIcons);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
/** true = Backdrop über Content anzeigen (Menü oder Suche offen). */
|
||||
export const overlayOpen = writable(false);
|
||||
|
||||
/** Callback zum Schließen (setzt im Header menuOpen/searchOpen auf false). */
|
||||
export const overlayClose = writable<(() => void) | null>(null);
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* RustyImage: Bilder über RustyCMS /api/transform laden, in public speichern, lokal ausliefern.
|
||||
* Hash aus URL + Transform-Parametern, damit gleiche Anfrage nicht erneut geladen wird.
|
||||
* Nur für Build/SSR (Node) – nutzt fs und fetch zur CMS-Basis-URL.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/** Transform-Parameter wie von der RustyCMS-API unterstützt. */
|
||||
export interface RustyImageTransformParams {
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Aspect ratio vor Skalierung, z. B. "1:1" oder "16:9" (Mittelpunkt-Crop). */
|
||||
ar?: string;
|
||||
/** fill | contain | cover */
|
||||
fit?: "fill" | "contain" | "cover";
|
||||
/** Ausgabeformat */
|
||||
format?: "jpeg" | "png" | "webp" | "avif";
|
||||
/** JPEG-Qualität 1–100 */
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FORMAT = "jpeg";
|
||||
const SUBDIR = "images/transformed";
|
||||
|
||||
function getCmsBaseUrl(): string {
|
||||
const url = import.meta.env.PUBLIC_CMS_URL;
|
||||
const base = (
|
||||
typeof url === "string" && url.trim() ? url : "http://localhost:3000"
|
||||
).replace(/\/$/, "");
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Stabile Dateiendung aus format. */
|
||||
function formatToExt(format: string): string {
|
||||
switch (format.toLowerCase()) {
|
||||
case "png":
|
||||
return "png";
|
||||
case "webp":
|
||||
return "webp";
|
||||
case "avif":
|
||||
return "avif";
|
||||
default:
|
||||
return "jpg";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt einen stabilen Hash aus URL und allen Transform-Parametern.
|
||||
* Gleiche URL + gleiche Params → gleicher Hash → keine erneute Transformation.
|
||||
*/
|
||||
export function hashForTransform(
|
||||
url: string,
|
||||
params: RustyImageTransformParams = {},
|
||||
): string {
|
||||
const normalized = {
|
||||
url,
|
||||
w: params.width,
|
||||
h: params.height,
|
||||
ar: params.ar,
|
||||
fit: params.fit ?? "contain",
|
||||
format: params.format ?? DEFAULT_FORMAT,
|
||||
quality: params.quality ?? 85,
|
||||
};
|
||||
const payload = JSON.stringify(normalized);
|
||||
return createHash("sha256").update(payload).digest("hex").slice(0, 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt sicher, dass das transformierte Bild in public existiert.
|
||||
* Wenn nicht: GET an RustyCMS /api/transform, Ergebnis in public/images/transformed/<hash>.<ext> schreiben.
|
||||
* Gibt den öffentlichen Pfad zurück (z. B. /images/transformed/abc123def456.jpg).
|
||||
*/
|
||||
export async function ensureTransformedImage(
|
||||
url: string,
|
||||
params: RustyImageTransformParams = {},
|
||||
): Promise<string> {
|
||||
const format = params.format ?? DEFAULT_FORMAT;
|
||||
const ext = formatToExt(format);
|
||||
const hash = hashForTransform(url, params);
|
||||
const { width, height } = params;
|
||||
const dimSuffix =
|
||||
width != null && height != null
|
||||
? `-${width}x${height}`
|
||||
: width != null
|
||||
? `-${width}w`
|
||||
: height != null
|
||||
? `-${height}h`
|
||||
: "";
|
||||
|
||||
const projectRoot =
|
||||
typeof process !== "undefined" && process.cwd ? process.cwd() : undefined;
|
||||
if (!projectRoot) {
|
||||
throw new Error("rusty-image: project root (process.cwd) not available");
|
||||
}
|
||||
|
||||
const publicDir = path.join(projectRoot, "public");
|
||||
const dir = path.join(publicDir, SUBDIR);
|
||||
const filename = `${hash}${dimSuffix}.${ext}`;
|
||||
const filePath = path.join(dir, filename);
|
||||
const publicPath = `/${SUBDIR}/${filename}`;
|
||||
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return publicPath;
|
||||
} catch {
|
||||
/* Datei existiert nicht, laden und schreiben */
|
||||
}
|
||||
|
||||
const baseUrl = getCmsBaseUrl();
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("url", url);
|
||||
if (params.width != null) searchParams.set("w", String(params.width));
|
||||
if (params.height != null) searchParams.set("h", String(params.height));
|
||||
if (params.ar != null) searchParams.set("ar", params.ar);
|
||||
if (params.fit != null) searchParams.set("fit", params.fit);
|
||||
if (params.format != null) searchParams.set("format", params.format);
|
||||
if (params.quality != null)
|
||||
searchParams.set("quality", String(params.quality));
|
||||
|
||||
const transformUrl = `${baseUrl}/api/transform?${searchParams.toString()}`;
|
||||
const res = await fetch(transformUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`rusty-image: transform failed ${res.status} for ${url}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
return publicPath;
|
||||
}
|
||||
|
||||
/** Ein Eintrag aus einem Bild-Array: URL-String oder aufgelöstes img-Objekt (src). */
|
||||
type ImageArrayItem = string | { src?: string };
|
||||
|
||||
function toImageUrl(item: ImageArrayItem): string | null {
|
||||
if (typeof item === "string")
|
||||
return item.startsWith("//") ? `https:${item}` : item;
|
||||
if (item && typeof item === "object" && typeof item.src === "string")
|
||||
return item.src.startsWith("//") ? `https:${item.src}` : item.src;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst ein Array von Bild-URLs/Referenzen über die Transform-API auf.
|
||||
* Akzeptiert string[] oder Objekte mit src (z. B. { _type: "img", src, description }).
|
||||
* Gibt ein Array von öffentlichen Pfaden zurück (z. B. für Fullwidth-Banner).
|
||||
*/
|
||||
export async function resolveImageUrls(
|
||||
items: ImageArrayItem[],
|
||||
params: RustyImageTransformParams = {},
|
||||
): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for (const item of items) {
|
||||
const u = toImageUrl(item);
|
||||
if (!u || !u.startsWith("http")) continue;
|
||||
try {
|
||||
result.push(await ensureTransformedImage(u, params));
|
||||
} catch (e) {
|
||||
console.warn("[rusty-image] resolve failed for", u, e);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Holt die Bild-URL aus einem Image-Block (image oder img: string, src oder file.url). */
|
||||
function getImageBlockUrl(block: {
|
||||
image?: string | { src?: string; file?: { url?: string }; title?: string };
|
||||
img?: string | { src?: string; file?: { url?: string }; title?: string };
|
||||
}): string | null {
|
||||
const source = block.image ?? block.img;
|
||||
if (typeof source === "string" && source.startsWith("http")) return source;
|
||||
if (source && typeof source === "object") {
|
||||
const u =
|
||||
"src" in source && typeof source.src === "string" ? source.src : source.file?.url;
|
||||
if (typeof u === "string") return u.startsWith("//") ? `https:${u}` : u;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** RowContentLayout-typ: Zeilen mit content-Arrays. */
|
||||
export interface RowContentLayoutLike {
|
||||
row1Content?: unknown[];
|
||||
row2Content?: unknown[];
|
||||
row3Content?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Geht alle Content-Rows durch und setzt bei Image-Blöcken resolvedImageSrc
|
||||
* (transformiert über RustyCMS und speichert in public).
|
||||
*/
|
||||
export async function resolveContentImages(
|
||||
layout: RowContentLayoutLike,
|
||||
transformParams: RustyImageTransformParams = {},
|
||||
): Promise<void> {
|
||||
const rows = [
|
||||
layout.row1Content,
|
||||
layout.row2Content,
|
||||
layout.row3Content,
|
||||
].filter((r): r is unknown[] => Array.isArray(r));
|
||||
|
||||
for (const content of rows) {
|
||||
for (const item of content) {
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
const block = item as {
|
||||
_type?: string;
|
||||
image?: unknown;
|
||||
img?: unknown;
|
||||
resolvedImageSrc?: string;
|
||||
};
|
||||
if (block._type !== "image") continue;
|
||||
const url = getImageBlockUrl(
|
||||
block as {
|
||||
image?: string | { file?: { url?: string }; title?: string };
|
||||
img?: string | { file?: { url?: string }; title?: string };
|
||||
},
|
||||
);
|
||||
if (!url) continue;
|
||||
try {
|
||||
block.resolvedImageSrc = await ensureTransformedImage(
|
||||
url,
|
||||
transformParams,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("[rusty-image] resolve failed for", url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user