diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 4a34c98..371f8c4 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,4 +1,4 @@ -import { getTranslationBundleBySlug } from '$lib/cms'; +import { getNormalizedTranslations } from '$lib/cms'; import { isCmsAvailable } from '$lib/cms-health.server'; import { maintenanceResponse } from '$lib/maintenance.server'; import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants'; @@ -97,23 +97,10 @@ export const handle: Handle = async ({ event, resolve }) => { } try { - const bundle = await getTranslationBundleBySlug(TRANSLATION_BUNDLE_SLUG, { locale: 'de' }); - if (bundle?.strings && typeof bundle.strings === 'object') { - const normalized: Record = {}; - for (const [key, val] of Object.entries(bundle.strings)) { - const v = val as unknown; - const str = - typeof v === 'string' - ? v - : typeof v === 'object' && v !== null && 'value' in v - ? (v as { value?: unknown }).value - : (v as { text?: unknown })?.text ?? (v as { label?: unknown })?.label; - if (typeof str === 'string') normalized[key] = str; - } - event.locals.translations = normalized; - } else { - event.locals.translations = {}; - } + event.locals.translations = await getNormalizedTranslations( + TRANSLATION_BUNDLE_SLUG, + { locale: 'de' }, + ); } catch (err) { event.locals.translations = {}; logWarn('hooks.translations', err); diff --git a/src/lib/blog-utils.ts b/src/lib/blog-utils.ts index 7c3c29f..11c37ae 100644 --- a/src/lib/blog-utils.ts +++ b/src/lib/blog-utils.ts @@ -326,26 +326,22 @@ export async function loadPostsList(opts: { for (const post of posts) { const field = getPostImageField(post.postImage); if (field) { - try { - const url = await ensureTransformedImage(field.url, { - width: 400, - height: 267, - fit: "cover", - format: "webp", - focalX: field.focal?.x, - focalY: field.focal?.y, - }); - const p = post as PostEntry & { - _resolvedImageUrl?: string; - _rawImageUrl?: string; - _imageFocal?: { x: number; y: number } | null; - }; - p._resolvedImageUrl = url; - p._rawImageUrl = field.url; - p._imageFocal = field.focal ?? null; - } catch { - /* image optional */ - } + const url = ensureTransformedImage(field.url, { + width: 400, + height: 267, + fit: "cover", + format: "webp", + focalX: field.focal?.x, + focalY: field.focal?.y, + }); + const p = post as PostEntry & { + _resolvedImageUrl?: string; + _rawImageUrl?: string; + _imageFocal?: { x: number; y: number } | null; + }; + p._resolvedImageUrl = url; + p._rawImageUrl = field.url; + p._imageFocal = field.focal ?? null; } } } catch (e) { @@ -512,15 +508,14 @@ export async function resolvePostOverviewBlocks( .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; + const settled = await Promise.all( + slugs.slice(0, limit).map((slug) => + getPostBySlug(slug, { locale: "de", resolve: ["all"] }).catch( + () => null, + ), + ), + ); + item.postsResolved = settled.filter((p): p is PostEntry => !!p); } } if (Array.isArray(item.postsResolved)) { @@ -531,26 +526,22 @@ export async function resolvePostOverviewBlocks( for (const post of item.postsResolved as PostEntry[]) { const field = getPostImageField(post.postImage); if (field) { - try { - const url = await ensureTransformedImage(field.url, { - width: 400, - height: 267, - fit: "cover", - format: "webp", - focalX: field.focal?.x, - focalY: field.focal?.y, - }); - const p = post as PostEntry & { - _resolvedImageUrl?: string; - _rawImageUrl?: string; - _imageFocal?: { x: number; y: number } | null; - }; - p._resolvedImageUrl = url; - p._rawImageUrl = field.url; - p._imageFocal = field.focal ?? null; - } catch { - // Bild optional - } + const url = ensureTransformedImage(field.url, { + width: 400, + height: 267, + fit: "cover", + format: "webp", + focalX: field.focal?.x, + focalY: field.focal?.y, + }); + const p = post as PostEntry & { + _resolvedImageUrl?: string; + _rawImageUrl?: string; + _imageFocal?: { x: number; y: number } | null; + }; + p._resolvedImageUrl = url; + p._rawImageUrl = field.url; + p._imageFocal = field.focal ?? null; } } } @@ -602,19 +593,28 @@ export async function resolveCalendarBlocks( 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); + const settled = await Promise.all( + raw.map(async (x): Promise => { + if ( + typeof x === "object" && + x !== null && + "terminZeit" in x && + "title" in x + ) { + return x as CalendarItemData; } - } - } - (item as { items?: CalendarItemData[] }).items = resolved; + const islug = + typeof x === "string" ? x : (x as { _slug?: string })?._slug; + if (!islug) return null; + const entry = await getCalendarItemBySlug(islug, { locale: "de" }).catch( + () => null, + ); + return entry ?? null; + }), + ); + (item as { items?: CalendarItemData[] }).items = settled.filter( + (e): e is CalendarItemData => !!e, + ); } } } diff --git a/src/lib/cms.ts b/src/lib/cms.ts index 87e93e0..88abb40 100644 --- a/src/lib/cms.ts +++ b/src/lib/cms.ts @@ -95,7 +95,10 @@ async function cached( key: string, fn: () => Promise, ): Promise { - if (import.meta.env.DEV) return fn(); + // Dev: Cache aus by default (sofortige Reflektion neuer CMS-Daten). + // Mit DEV_CACHE_CMS=1 anschalten — sinnvoll wenn lokal gegen Prod-CMS + // entwickelt wird, sonst werden Netzwerk-Roundtrips bei jedem Request fällig. + if (import.meta.env.DEV && process.env.DEV_CACHE_CMS !== "1") return fn(); const hit = cache.get(key); if (hit && hit.expires > Date.now()) return hit.value as T; const pending = inflight.get(key) as Promise | undefined; @@ -847,6 +850,37 @@ export async function getTranslationBundleBySlug( }); } +/** + * Normalised key→string map for `event.locals.translations`. Cached on the + * raw `strings` object identity so the per-request `Object.entries` loop runs + * once per bundle reload (every 5 min) instead of every request. + */ +const normalizedTranslations = new WeakMap>(); + +export async function getNormalizedTranslations( + slug: string, + options?: { locale?: string }, +): Promise> { + const bundle = await getTranslationBundleBySlug(slug, options); + const strings = bundle?.strings; + if (!strings || typeof strings !== "object") return {}; + const cached = normalizedTranslations.get(strings as object); + if (cached) return cached; + const out: Record = {}; + for (const [key, val] of Object.entries(strings as Record)) { + const str = + typeof val === "string" + ? val + : typeof val === "object" && val !== null && "value" in val + ? (val as { value?: unknown }).value + : (val as { text?: unknown })?.text ?? + (val as { label?: unknown })?.label; + if (typeof str === "string") out[key] = str; + } + normalizedTranslations.set(strings as object, out); + return out; +} + /** Kalender-Eintrag (calendar) – aus OpenAPI-Spec. */ export type CalendarEntry = components["schemas"]["calendar"]; diff --git a/src/lib/components/BlockRenderer.svelte b/src/lib/components/BlockRenderer.svelte index 6dafb3a..e5117a8 100644 --- a/src/lib/components/BlockRenderer.svelte +++ b/src/lib/components/BlockRenderer.svelte @@ -18,7 +18,6 @@ import OpnFormBlock from "./blocks/OpnFormBlock.svelte"; import InternalComponentBlock from "./blocks/InternalComponentBlock.svelte"; import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte"; - import StrommixBlock from "./blocks/StrommixBlock.svelte"; import WindkarteBlock from "./blocks/WindkarteBlock.svelte"; import type { Translations } from "$lib/translations"; import type { @@ -93,7 +92,11 @@ {:else if type === "deadline_banner"} {:else if type === "live_strommix"} - + + {#await import("./blocks/StrommixBlock.svelte") then m} + {@const Comp = m.default} + + {/await} {:else if type === "wind_map"} {:else} diff --git a/src/lib/rusty-image.ts b/src/lib/rusty-image.ts index 170a655..b03fa2f 100644 --- a/src/lib/rusty-image.ts +++ b/src/lib/rusty-image.ts @@ -108,7 +108,7 @@ export function getTransformUrl(url: string, params: RustyImageTransformParams = } /** Alias für Kompatibilität (z. B. rustyastro / Layout). */ -export async function ensureTransformedImage(url: string, params: RustyImageTransformParams = {}): Promise { +export function ensureTransformedImage(url: string, params: RustyImageTransformParams = {}): string { return getCmsImageUrl(url, params); } diff --git a/src/lib/sanitize-blocks.server.ts b/src/lib/sanitize-blocks.server.ts index 7c9ee06..f3b6c05 100644 --- a/src/lib/sanitize-blocks.server.ts +++ b/src/lib/sanitize-blocks.server.ts @@ -62,8 +62,30 @@ const HTML_BLOCK_OPTIONS: sanitizeHtml.IOptions = { }, }; +/** + * Pro-Block-Hash-Cache: sanitize-html ist teuer (~ms pro KB), das CMS + * liefert dasselbe Markup wieder und wieder. Key = raw input, Value = + * sanitised output. Größe geclippt (LRU-ähnlich via Map-Insertion-Order). + */ +const SANITIZE_CACHE = new Map(); +const SANITIZE_CACHE_MAX = 500; + export function sanitizeUntrustedHtml(html: string): string { - return sanitizeHtml(html, HTML_BLOCK_OPTIONS); + const cached = SANITIZE_CACHE.get(html); + if (cached !== undefined) { + // Move to end (most-recently-used). + SANITIZE_CACHE.delete(html); + SANITIZE_CACHE.set(html, cached); + return cached; + } + const out = sanitizeHtml(html, HTML_BLOCK_OPTIONS); + SANITIZE_CACHE.set(html, out); + if (SANITIZE_CACHE.size > SANITIZE_CACHE_MAX) { + // Evict oldest. + const oldest = SANITIZE_CACHE.keys().next().value; + if (oldest !== undefined) SANITIZE_CACHE.delete(oldest); + } + return out; } type Block = { _type?: string; html?: unknown } & Record; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 0474022..7e89c87 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -293,7 +293,7 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { } } catch (err) { logWarn('layout.logoSvgFetch', err, { url: u }); } } else { - logoUrl = await ensureTransformedImage(u, { + logoUrl = ensureTransformedImage(u, { height: 56, fit: 'contain', format: 'webp', @@ -313,7 +313,7 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { try { const isSvgLogo = logoUrl?.split('?')[0].toLowerCase().endsWith('.svg'); const source = (logoUrl && !isSvgLogo) ? logoUrl : DEFAULT_SOCIAL_IMAGE_URL; - const transformed = await ensureTransformedImage(source, SOCIAL_IMAGE_TRANSFORM); + const transformed = ensureTransformedImage(source, SOCIAL_IMAGE_TRANSFORM); logoSocialImage = transformed.startsWith('/') ? `${siteBaseUrl}${transformed}` : transformed; diff --git a/src/routes/[...slug]/+page.server.ts b/src/routes/[...slug]/+page.server.ts index ac451d5..0874026 100644 --- a/src/routes/[...slug]/+page.server.ts +++ b/src/routes/[...slug]/+page.server.ts @@ -51,11 +51,18 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead error(404, 'Seite nicht gefunden'); } + // resolveContentImages baut nur URLs zusammen (kein I/O) und mutiert den + // Tree — vor den Block-Resolvern erledigen, die danach gegen mutierte + // Blocks arbeiten. await resolveContentImages(page); + // PostOverview liefert tagsMap, den SearchableText braucht. Calendar + + // DeadlineBanner sind unabhängig → parallel mit SearchableText laufen. const tagsMap = await resolvePostOverviewBlocks(page); - await resolveSearchableTextBlocks(page, tagsMap); - await resolveCalendarBlocks(page); - await resolveDeadlineBannerBlocks(page); + await Promise.all([ + resolveSearchableTextBlocks(page, tagsMap), + resolveCalendarBlocks(page), + resolveDeadlineBannerBlocks(page), + ]); sanitizeBlocks(page); // Resolve top banner if present diff --git a/src/routes/post/[slug]/+page.server.ts b/src/routes/post/[slug]/+page.server.ts index 5c466ea..8e3f11f 100644 --- a/src/routes/post/[slug]/+page.server.ts +++ b/src/routes/post/[slug]/+page.server.ts @@ -55,7 +55,7 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders }) const postImageField = getPostImageField(post.postImage); const postImageUrl = postImageField - ? await ensureTransformedImage(postImageField.url, { + ? ensureTransformedImage(postImageField.url, { width: 150, height: 200, fit: 'cover', @@ -67,7 +67,7 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders }) const siteBaseUrl = (env.PUBLIC_SITE_URL || '').replace(/\/$/, ''); const canonical = `${siteBaseUrl}/post/${slug}/`; const postSocialImageRel = postImageField - ? await ensureTransformedImage(postImageField.url, { + ? ensureTransformedImage(postImageField.url, { ...POST_SOCIAL_IMAGE_TRANSFORM, focalX: postImageField.focal?.x, focalY: postImageField.focal?.y,