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:
+37
-3
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user