Update project configuration and enhance translation support. Added caching mechanism for CMS responses with a new script, updated .gitignore to include cache files, and introduced translation handling in various components. Enhanced package.json with new scripts for cache warming and deployment. Added new translation-related components and improved existing ones for better localization support.

This commit is contained in:
Peter Meier
2026-03-17 22:32:31 +01:00
parent 1f5454d04e
commit daa4ea17b1
46 changed files with 5207 additions and 781 deletions
+37 -3
View File
@@ -1,9 +1,11 @@
/**
* Gemeinsame Typen für Content-Blöcke (Rows, Markdown, …).
* Wird von Astro-Seiten und Svelte-Komponenten genutzt.
* Kalender-Typen basieren auf der OpenAPI-Spec (cms-api.generated).
*/
import type { BlockLayout } from "./block-layout";
import type { components } from "./cms-api.generated";
/** Aufgelöster Block vom CMS (mit _type, _slug + typ-spezifische Felder). */
export type ResolvedBlock = {
@@ -25,12 +27,14 @@ export interface RowContentLayout {
row3AlignItems?: string;
}
/** Resolved Markdown-Block vom CMS (_type: "markdown"). */
/** Resolved Markdown-Block vom CMS (_type: "markdown"). resolvedContent = HTML mit transformierten Bildern (von resolveContentImages). */
export interface MarkdownBlockData {
_type?: "markdown";
_slug?: string;
name?: string;
content?: string;
/** Gesetzt von resolveContentImages: HTML (Bilder transformiert, in Links eingewickelt). */
resolvedContent?: string;
alignment?: "left" | "center" | "right";
layout?: BlockLayout;
}
@@ -68,11 +72,25 @@ export interface ImageBlockData {
_slug?: string;
image?:
| string
| { _type?: "img"; _slug?: string; src?: string; description?: string; file?: { url?: string }; title?: 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 };
| {
_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;
@@ -176,3 +194,19 @@ export interface SearchableTextBlockData {
textFragments?: (string | SearchableTextFragment)[];
layout?: BlockLayout;
}
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time. */
export type CalendarItemData = components["schemas"]["calendar_item"];
/** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */
export type CalendarBlockData = Omit<
components["schemas"]["calendar"],
"items"
> & {
_type?: "calendar";
/** Optionaler Titel über dem Kalender. */
title?: string;
/** Slugs oder aufgelöste calendar_item-Einträge. */
items?: (string | CalendarItemData)[];
layout?: BlockLayout;
};
+69 -1
View File
@@ -1,6 +1,14 @@
import type { PostEntry } from "./cms";
import { getPosts, getPostBySlug, getTags, getTextFragmentBySlug } from "./cms";
import {
getPosts,
getPostBySlug,
getTags,
getTextFragmentBySlug,
getCalendarBySlug,
getCalendarItemBySlug,
} from "./cms";
import type { RowContentLayout } from "./block-types";
import type { CalendarItemData } 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. */
@@ -253,6 +261,66 @@ export async function resolvePostOverviewBlocks(
return tagsMap;
}
function isCalendarBlock(
item: unknown,
): item is { _type?: string; _slug?: string; items?: unknown[] } {
return (
typeof item === "object" &&
item !== null &&
(item as { _type?: string })._type === "calendar"
);
}
/**
* Löst Calendar-Blöcke auf: Lädt Kalender per Slug und setzt block.items
* mit den aufgelösten calendar_item-Einträgen (title, terminZeit, description, link).
*/
export async function resolveCalendarBlocks(
layout: RowContentLayout,
): Promise<void> {
const rows = [
layout.row1Content ?? [],
layout.row2Content ?? [],
layout.row3Content ?? [],
];
for (const content of rows) {
if (!Array.isArray(content)) continue;
for (const item of content) {
if (!isCalendarBlock(item)) continue;
const slug = item._slug;
if (!slug) continue;
const rawItems = item.items ?? [];
const first = rawItems[0];
const alreadyResolved =
typeof first === "object" &&
first !== null &&
"terminZeit" in first &&
"title" in first;
if (alreadyResolved) continue;
const calendar = await getCalendarBySlug(slug, {
locale: "de",
resolve: "items",
});
const raw = calendar?.items ?? rawItems;
const resolved: CalendarItemData[] = [];
for (const x of raw) {
if (typeof x === "object" && x !== null && "terminZeit" in x && "title" in x) {
resolved.push(x as CalendarItemData);
} else {
const islug = typeof x === "string" ? x : (x as { _slug?: string })?._slug;
if (islug) {
const entry = await getCalendarItemBySlug(islug, { locale: "de" });
if (entry) resolved.push(entry);
}
}
}
(item as { items?: CalendarItemData[] }).items = resolved;
}
}
}
function isSearchableTextBlock(
item: unknown,
): item is { _type?: string; textFragments?: unknown[] } {
+3383 -353
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
/**
* Nur das Cache-Flag (ob in diesem Request aus Cache gelesen wurde).
* Keine Node-Abhängigkeiten kann von Layout/Middleware importiert werden.
* Die eigentliche Cache-Logik (Node fs/crypto) lebt in cms-cache.ts und wird nur per dynamic import geladen.
*/
let usedCacheThisRequest = false;
export function resetCmsFromCache(): void {
usedCacheThisRequest = false;
}
export function getCmsFromCache(): boolean {
return usedCacheThisRequest;
}
export function setCmsFromCacheUsed(): void {
usedCacheThisRequest = true;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Cache für CMS-API-Responses (nur serverseitig, Node fs/crypto).
* Wird ausschließlich per dynamic import aus cms.ts geladen nie von Layout/Middleware,
* damit es nicht ins Client-Bundle gelangt. Flag/State: cms-cache-state.ts.
*/
import { createHash } from "node:crypto";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { setCmsFromCacheUsed } from "./cms-cache-state";
const CACHE_DIR = `${process.cwd()}/.cache/cms`;
function cacheKeyFromUrl(url: string): string {
return createHash("sha256").update(url).digest("hex");
}
async function ensureCacheDir(): Promise<void> {
if (!existsSync(CACHE_DIR)) {
await mkdir(CACHE_DIR, { recursive: true });
}
}
interface CachedResponse {
status: number;
body: string;
}
async function getCached(key: string): Promise<CachedResponse | null> {
try {
const path = `${CACHE_DIR}/${key}.json`;
if (!existsSync(path)) return null;
const raw = await readFile(path, "utf-8");
const data = JSON.parse(raw) as CachedResponse;
return data?.body != null ? data : null;
} catch {
return null;
}
}
async function setCached(key: string, data: CachedResponse): Promise<void> {
try {
await ensureCacheDir();
const path = `${CACHE_DIR}/${key}.json`;
await writeFile(path, JSON.stringify(data), "utf-8");
} catch {
// Cache schreiben optional
}
}
/**
* Fetch mit Cache: bei Erfolg wird die Response in .cache/cms gespeichert (überschreibt vorhanden).
* Bei Fehler wird versucht, aus dem Cache zu lesen; wenn gefunden, wird usedCacheThisRequest gesetzt.
*/
export async function cachedFetch(url: string): Promise<Response> {
const key = cacheKeyFromUrl(url);
try {
const res = await fetch(url);
const body = await res.text();
await setCached(key, { status: res.status, body });
return new Response(body, {
status: res.status,
headers: res.headers,
});
} catch {
const cached = await getCached(key);
if (cached) {
setCmsFromCacheUsed();
return new Response(cached.body, {
status: cached.status,
headers: { "Content-Type": "application/json" },
});
}
throw new Error(`CMS nicht erreichbar (${url}) und kein Cache für diese Anfrage.`);
}
}
+168 -103
View File
@@ -1,6 +1,7 @@
/**
* RustyCMS-Client: OpenAPI-Spec laden, Page-Content abrufen.
* Basis-URL über Umgebungsvariable PUBLIC_CMS_URL (z.B. http://localhost:3000).
* API-Responses werden in .cache/cms gespeichert; bei nicht erreichbarem CMS wird aus dem Cache gelesen.
*
* 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.
@@ -9,6 +10,25 @@
import type { components, operations } from "./cms-api.generated";
/** Lazy-Import nur bei SSR cms-cache (Node-only) darf nicht ins Client-Bundle. */
let cachedFetchFn: ((url: string) => Promise<Response>) | null = null;
async function getCachedFetch(): Promise<(url: string) => Promise<Response>> {
if (!cachedFetchFn) {
if (import.meta.env.SSR) {
// In Dev immer direkt zur API, damit sofort sichtbar ist, was die API liefert (kein Disk-Cache).
if (import.meta.env.DEV) {
cachedFetchFn = (url: string) => fetch(url);
} else {
const m = await import("./cms-cache");
cachedFetchFn = m.cachedFetch;
}
} else {
cachedFetchFn = (url: string) => fetch(url);
}
}
return cachedFetchFn;
}
const getBaseUrl = (): string => {
const url = import.meta.env.PUBLIC_CMS_URL;
const base = (
@@ -43,6 +63,8 @@ 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>>();
/** In Dev keinen List-Cache, damit neue CMS-Inhalte sofort erscheinen. */
const skipListCache = typeof import.meta.env !== "undefined" && import.meta.env.DEV;
/**
* Lädt die OpenAPI-Spezifikation von GET /api-docs/openapi.json.
@@ -51,7 +73,7 @@ const listCache = new Map<string, Promise<unknown>>();
export async function fetchOpenApi(): Promise<OpenApiSpec> {
if (openApiCache) return openApiCache;
const base = getBaseUrl();
const res = await fetch(`${base}/api-docs/openapi.json`);
const res = await (await getCachedFetch())(`${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}?`,
@@ -67,21 +89,22 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
*/
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);
if (!skipListCache) {
const p = listCache.get(key) as Promise<PageEntry[]> | undefined;
if (p) return p;
}
const p = (async () => {
const base = getBaseUrl();
const res = await (await getCachedFetch())(`${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 ?? [];
})();
if (!skipListCache) listCache.set(key, p);
return p;
}
@@ -97,7 +120,7 @@ export async function getPageConfigs(options?: {
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());
const res = await (await getCachedFetch())(url.toString());
if (!res.ok) {
throw new Error(
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
@@ -116,26 +139,27 @@ export async function getPageConfigBySlug(
): 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);
if (!skipListCache) {
const p = listCache.get(key) as Promise<PageConfigEntry | null> | undefined;
if (p) return p;
}
const 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 (await getCachedFetch())(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;
})();
if (!skipListCache) listCache.set(key, p);
return p;
}
@@ -181,12 +205,12 @@ export async function getPageBySlug(
options?: ContentGetOptions,
): Promise<PageEntry | null> {
const base = getBaseUrl();
const trySlug = (s: string): Promise<Response> => {
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());
return (await getCachedFetch())(u.toString());
};
let res = await trySlug(slug);
if (res.status === 404 && !slug.startsWith("/")) {
@@ -250,40 +274,39 @@ export async function getNavigations(options?: {
}> {
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);
if (!skipListCache) {
const p = listCache.get(key) as Promise<{
items: NavigationEntry[];
page: number;
per_page: number;
total: number;
total_pages: number;
}> | undefined;
if (p) return p;
}
const 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 (await getCachedFetch())(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,
};
})();
if (!skipListCache) listCache.set(key, p);
return p;
}
@@ -332,7 +355,7 @@ export async function getNavigationBySlug(
`${base}/api/content/navigation/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as NavigationEntry;
@@ -354,7 +377,7 @@ export async function getFooterBySlug(
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());
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
@@ -384,12 +407,12 @@ export async function getPostBySlug(
options?: ContentGetOptions,
): Promise<PostEntry | null> {
const base = getBaseUrl();
const trySlug = (s: string): Promise<Response> => {
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());
return (await getCachedFetch())(u.toString());
};
let res = await trySlug(slug);
if (res.status === 404 && !slug.startsWith("/")) {
@@ -441,21 +464,22 @@ export async function getPosts(
? 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);
if (!skipListCache) {
const p = listCache.get(key) as Promise<PostEntry[]> | undefined;
if (p) return p;
}
const 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 (await getCachedFetch())(url.toString());
if (!res.ok) throw new Error("RustyCMS list post failed");
const data = (await res.json()) as { items?: PostEntry[] };
return data.items ?? [];
})();
if (!skipListCache) listCache.set(key, p);
return p;
}
@@ -465,17 +489,18 @@ 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);
if (!skipListCache) {
const p = listCache.get(key) as Promise<TagEntry[]> | undefined;
if (p) return p;
}
const p = (async () => {
const base = getBaseUrl();
const res = await (await getCachedFetch())(`${base}/api/content/tag`);
if (!res.ok) return [];
const data = (await res.json()) as { items?: TagEntry[] };
return data.items ?? [];
})();
if (!skipListCache) listCache.set(key, p);
return p;
}
@@ -492,7 +517,7 @@ export async function getTextFragmentBySlug(
`${base}/api/content/text_fragment/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
@@ -515,7 +540,7 @@ export async function getFullwidthBannerBySlug(
`${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
@@ -541,7 +566,7 @@ export async function getTranslationBySlug(
`${base}/api/content/translation/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
return null;
@@ -558,7 +583,7 @@ export type TranslationBundleEntry = {
/**
* Translation-Bundle anhand Slug (GET /api/content/translation_bundle/:slug).
* Liefert ein Objekt mit allen UI-Strings (z. B. strings.searchable_text_help).
* Slug z. B. "de" für content/de/translation_bundle/de.json5.
* Mit _resolve=all werden Referenzen in strings aufgelöst (z. B. blog_tag_all).
*/
export async function getTranslationBundleBySlug(
slug: string,
@@ -568,11 +593,51 @@ export async function getTranslationBundleBySlug(
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());
const res = await (await getCachedFetch())(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 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 (await getCachedFetch())(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 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 (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as CalendarItemEntry;
}
+5 -2
View File
@@ -25,11 +25,14 @@ export const ROW_RESOLVE: string[] = [
/** 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"];
/** Resolve für Post-Requests: "all" damit auch img in row1Content/Image-Blöcken aufgelöst wird (src), sonst nur Referenz. */
export const POST_RESOLVE = ["all"];
/** Fallback-Slug für die Startseite, wenn page_config keine homePage liefert. */
export const DEFAULT_HOME_PAGE_SLUG = "page-home";
/** Site-Name für og:site_name und JSON-LD (in Layout via PUBLIC_SITE_NAME überschreibbar). */
export const SITE_NAME = "RustyAstro";
/** Slug des Translation-Bundles im CMS (content/de/translation_bundle/{slug}.json5). Alle UI-Texte aus diesem Bundle. */
export const TRANSLATION_BUNDLE_SLUG = "app";
+101 -8
View File
@@ -24,6 +24,8 @@ export interface RustyImageTransformParams {
const DEFAULT_FORMAT = "jpeg";
const SUBDIR = "images/transformed";
/** Persistenter Cache für Builds überlebt "astro build" (dist wird geleert). */
const TRANSFORM_CACHE_DIR = ".cache/transformed-images";
function getCmsBaseUrl(): string {
const url = import.meta.env.PUBLIC_CMS_URL;
@@ -105,11 +107,25 @@ export async function ensureTransformedImage(
const filePath = path.join(dir, filename);
const publicPath = `/${SUBDIR}/${filename}`;
try {
await fs.access(filePath);
return publicPath;
} catch {
/* Datei existiert nicht, laden und schreiben */
// Build: zuerst persistenten Cache prüfen (dist wird bei jedem Build geleert).
const cacheDir = path.join(projectRoot, TRANSFORM_CACHE_DIR);
const cacheFilePath = path.join(cacheDir, filename);
if (isBuild) {
try {
await fs.access(cacheFilePath);
await fs.mkdir(dir, { recursive: true });
await fs.copyFile(cacheFilePath, filePath);
return publicPath;
} catch {
/* nicht im Cache, laden und in Cache + dist schreiben */
}
} else {
try {
await fs.access(filePath);
return publicPath;
} catch {
/* Datei existiert nicht, laden und schreiben */
}
}
const baseUrl = getCmsBaseUrl();
@@ -135,6 +151,10 @@ export async function ensureTransformedImage(
const buffer = Buffer.from(await res.arrayBuffer());
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, buffer);
if (isBuild) {
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(cacheFilePath, buffer);
}
return publicPath;
}
@@ -203,14 +223,73 @@ export interface RowContentLayoutLike {
row3Content?: unknown[];
}
/** Parameter für resolveContentImages (optional baseUrl für Markdown-Bild-Links). */
export interface ResolveContentImagesOptions {
transformParams?: RustyImageTransformParams;
/** Basis-URL für Links auf Bilder (z. B. Astro.url.origin oder SITE). Fehlt → aus import.meta.env.SITE. */
baseUrl?: string;
}
/** Markdown-Inline-Bilder: 1200px Breite, WebP, Link öffnet dasselbe Bild in neuem Tab. */
const MARKDOWN_IMAGE_PARAMS: RustyImageTransformParams = {
width: 1200,
fit: "contain",
format: "webp",
quality: 85,
};
const IMG_SRC_REGEX =
/<img\s+([^>]*?)src\s*=\s*["']([^"']+)["']([^>]*)>/gi;
/**
* Geht alle Content-Rows durch und setzt bei Image-Blöcken resolvedImageSrc
* (transformiert über RustyCMS und speichert in public).
* Ersetzt in HTML alle <img src="...">: Bild-URL wird transformiert (RustyImage),
* Bild wird in <a href="..." target="_blank"> eingewickelt (öffnet Bild in neuem Tab).
* href ist relativ (z. B. /images/transformed/…), damit auf localhost und Production dieselbe Seite funktioniert.
*/
export async function processMarkdownHtmlImages(
html: string,
_baseUrl?: string,
transformParams: RustyImageTransformParams = MARKDOWN_IMAGE_PARAMS,
): Promise<string> {
const matches = [...html.matchAll(IMG_SRC_REGEX)];
const replacements: { index: number; length: number; newBlock: string }[] = [];
for (const m of matches) {
const rawSrc = m[2];
const url = rawSrc.startsWith("//") ? `https:${rawSrc}` : rawSrc;
if (!url.startsWith("http")) continue;
try {
const transformedPath = await ensureTransformedImage(url, transformParams);
const href = transformedPath.startsWith("/") ? transformedPath : `/${transformedPath}`;
const newImg = m[0].replace(rawSrc, transformedPath);
const newBlock = `<a href="${href}" target="_blank" rel="noopener noreferrer" class="markdown-image-link">${newImg}</a>`;
replacements.push({ index: m.index ?? 0, length: m[0].length, newBlock });
} catch (e) {
console.warn("[rusty-image] markdown img resolve failed for", url, e);
}
}
if (replacements.length === 0) return html;
replacements.sort((a, b) => b.index - a.index);
let out = html;
for (const r of replacements) {
out = out.slice(0, r.index) + r.newBlock + out.slice(r.index + r.length);
}
return out;
}
/**
* Geht alle Content-Rows durch: Image-Blöcke → resolvedImageSrc;
* Markdown-Blöcke → resolvedContent (HTML mit transformierten Bildern + Link-Wrapper).
*/
export async function resolveContentImages(
layout: RowContentLayoutLike,
transformParams: RustyImageTransformParams = {},
transformParamsOrOptions: RustyImageTransformParams | ResolveContentImagesOptions = {},
): Promise<void> {
const options: ResolveContentImagesOptions =
transformParamsOrOptions && "baseUrl" in transformParamsOrOptions
? (transformParamsOrOptions as ResolveContentImagesOptions)
: { transformParams: transformParamsOrOptions as RustyImageTransformParams };
const transformParams = options.transformParams ?? {};
const rows = [
layout.row1Content,
layout.row2Content,
@@ -227,16 +306,30 @@ export async function resolveContentImages(
...transformParams,
};
const { marked } = await import("marked");
marked.setOptions({ gfm: true });
for (const content of rows) {
for (const item of content) {
if (typeof item !== "object" || item === null) continue;
const block = item as {
_type?: string;
content?: string;
image?: unknown;
img?: unknown;
images?: Array<{ src?: string; file?: { url?: string }; resolvedSrc?: string }>;
resolvedImageSrc?: string;
resolvedContent?: string;
};
if (block._type === "markdown" && typeof block.content === "string" && block.content.trim()) {
try {
const html = marked.parse(block.content) as string;
block.resolvedContent = await processMarkdownHtmlImages(html);
} catch (e) {
console.warn("[rusty-image] markdown block resolve failed", e);
}
continue;
}
if (block._type === "image") {
const url = getImageBlockUrl(
block as {
+186
View File
@@ -0,0 +1,186 @@
/**
* Zentrale Übersetzungen (i18n-ähnlich).
* Quelle: CMS translation_bundle (API, Middleware setzt Astro.locals.translations).
* Wird in Layout geladen und über TranslationProvider/Svelte-Context bereitgestellt.
*
* Wichtig für client:load-Inseln: useTranslate() hat dort keinen Context und beim SSR kein window,
* daher fehlen Übersetzungen (es erscheint der Key). Lösung: Übersetzungen als Prop von der
* Astro-Seite übergeben und t(translations, key, replacements) nutzen. Siehe .cursor/rules/translations-i18n.mdc.
*/
import { getContext } from "svelte";
export type Translations = Record<string, string>;
/** Context-Key für die t-Funktion (von TranslationProvider gesetzt). */
export const T_CONTEXT_KEY = Symbol("translations.t");
/** Platzhalter für Ersetzung: z. B. { name: "Max" } ersetzt {{name}} im Übersetzungstext. */
export type TranslationReplacements = Record<string, string | number>;
/** t(key) oder t(key, { placeholder: value }) mit optionalen Ersetzungen für {{placeholder}}. */
export type TFunction = (
key: string,
replacements?: TranslationReplacements,
) => string;
/**
* Ersetzt {{name}} im Text durch replacements[name]; fehlende Keys bleiben als {{name}}.
*/
export function replacePlaceholders(
text: string,
replacements?: TranslationReplacements | null,
): string {
if (!replacements || typeof text !== "string") return text;
return text.replace(/\{\{(\w+)\}\}/g, (_, name: string) => {
const val = replacements[name];
return val !== undefined && val !== null ? String(val) : `{{${name}}}`;
});
}
/** Liest Übersetzungen aus window oder aus script#astro-translations (Fallback für Inseln). */
function getTranslationsFromWindow(): Translations | null {
if (typeof window === "undefined") return null;
const w = window as unknown as { __TRANSLATIONS__?: Translations };
if (w.__TRANSLATIONS__ && typeof w.__TRANSLATIONS__ === "object") return w.__TRANSLATIONS__;
try {
const el = document.getElementById("astro-translations");
const json =
el?.getAttribute("data-translations") ??
(el as HTMLScriptElement | null)?.textContent?.trim();
if (json) {
const parsed = JSON.parse(json) as Translations;
w.__TRANSLATIONS__ = parsed;
return parsed;
}
} catch {
/* ignore */
}
return null;
}
/**
* t-Funktion aus Svelte-Context oder aus window.__TRANSLATIONS__.
* Nur zuverlässig innerhalb des TranslationProvider-Baums. Für client:load-Inseln:
* Übersetzungen als Prop übergeben und statische t(translations, key) nutzen (siehe Regel translations-i18n).
*/
export function useTranslate(): TFunction {
const fromContext = getContext<TFunction | undefined>(T_CONTEXT_KEY);
if (fromContext) return fromContext;
const warnedKeys = new Set<string>();
return (key: string, replacements?: TranslationReplacements): string => {
const translations = getTranslationsFromWindow();
const raw = translations?.[key] ?? key;
if (
typeof import.meta.env !== "undefined" &&
import.meta.env.DEV &&
raw === key &&
key.length > 0 &&
!warnedKeys.has(key)
) {
warnedKeys.add(key);
console.warn(
`[translations] Key "${key}" in Insel nicht gefunden (kein Context/window). ` +
`Übersetzungen als Prop von der Astro-Seite übergeben und t(translations, key) nutzen. Siehe .cursor/rules/translations-i18n.mdc`,
);
}
return replacePlaceholders(raw, replacements);
};
}
/** Bekannte Übersetzungs-Keys nur hier eintragen, Nutzung über T.xy (Autocomplete, keine Tippfehler). */
const TRANSLATION_KEYS = [
"searchable_text_help",
"searchable_text_help_aria",
"searchable_text_placeholder",
"searchable_text_search_aria",
"searchable_text_clear_search",
"searchable_text_tag_all",
"searchable_text_no_results",
"searchable_text_count_all",
"searchable_text_count_filtered",
"searchable_text_copy_aria",
"searchable_text_copy_button",
"searchable_text_copied",
"searchable_text_untitled",
"footer_copyright",
"nav_start",
"nav_posts",
"nav_home",
"header_search_open",
"header_search_close",
"header_menu_open",
"header_menu_close",
"header_nav_aria",
"header_overlay_close",
"header_social_aria",
"site_name_fallback",
"pagination_prev",
"pagination_next",
"blog_tag_all",
"blog_filter_aria",
"blog_count",
"page_404_title",
"page_404_text",
"page_404_back",
"posts_title",
"posts_subtitle",
"posts_tag_title",
"posts_tag_subtitle",
"posts_cms_error",
"iframe_activate_aria",
"iframe_missing",
"image_gallery_swipe_aria",
"image_gallery_prev_aria",
"image_gallery_next_aria",
"image_gallery_position_aria",
"image_gallery_slide_aria",
"image_gallery_empty",
"youtube_missing",
"youtube_title_fallback",
"calendar_prev_month",
"calendar_next_month",
"calendar_weekday_mo",
"calendar_weekday_di",
"calendar_weekday_mi",
"calendar_weekday_do",
"calendar_weekday_fr",
"calendar_weekday_sa",
"calendar_weekday_so",
"calendar_more_info",
"calendar_no_events",
"calendar_time_suffix",
"calendar_select_day",
"calendar_next_events",
"calendar_show_more",
"calendar_show_less",
] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
/** Keys als Objekt: T.searchable_text_help → "searchable_text_help" (für t(T.xy)). */
export const T: { [K in TranslationKey]: K } = Object.fromEntries(
TRANSLATION_KEYS.map((k) => [k, k]),
) as { [K in TranslationKey]: K };
/**
* Platzhalter im CMS-Text: {{name}} wird durch t(key, { name: "Wert" }) ersetzt.
* Beispiel im CMS: "Hallo {{user}}, du hast {{count}} Einträge."
* Aufruf: t(T.xyz, { user: "Max", count: 5 })
*/
/**
* Gibt den übersetzten Text für key zurück, optional mit Ersetzung von {{placeholder}}.
* Fallback: key selbst (damit fehlende Einträge im CMS erkennbar sind).
* Nutzbar in Astro und in Svelte-Inseln: Übersetzungen von der Astro-Seite als Prop übergeben,
* dann in der Komponente z. B. `t(translations ?? null, T.xy, { count: 5 })` (siehe BlogOverview.svelte).
*/
export function t(
translations: Translations | null | undefined,
key: string,
replacements?: TranslationReplacements,
): string {
if (!key) return key;
const raw = translations?.[key] ?? key;
return replacePlaceholders(raw, replacements);
}