81c82c33e6
- blog-utils.resolvePostOverviewBlocks: parallel getPostBySlug via Promise.all + .catch(null) instead of sequential await loop. - blog-utils.resolveCalendarBlocks: same fix for calendar items. - [...slug] page load: SearchableText/Calendar/DeadlineBanner now run parallel after tagsMap (was strict waterfall). - sanitize-blocks: LRU cache keyed on raw html input — sanitize-html is ~ms/KB, the same content repeats across pages and cache hits. - cms.getNormalizedTranslations: WeakMap-memoised the per-request Object.entries normalize loop on top of the existing 5-min bundle TTL. - cms.cached: opt-in dev cache via DEV_CACHE_CMS=1 (default still off). - rusty-image.ensureTransformedImage: was async-wrapping a sync URL builder; made sync, removed awaits at call sites — saves microtasks. - BlockRenderer: lazy-load StrommixBlock (~220 KB) via dynamic import in the template — pages without the live-strommix widget no longer ship its bundle. Also: enabled Caddy zstd+gzip on windwiderstand.de vhost (server-side edit; verified `content-encoding: zstd` on response). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
165 lines
5.9 KiB
TypeScript
165 lines
5.9 KiB
TypeScript
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';
|
|
import { env as envPublic } from '$env/dynamic/public';
|
|
import { logWarn } from '$lib/log.server';
|
|
import { lookupRedirect } from '$lib/redirects.server';
|
|
import type { Handle } from '@sveltejs/kit';
|
|
|
|
/**
|
|
* Page-Routen: kurze Edge-Cache + SWR. Webhook purged App-Cache,
|
|
* Edge läuft via stale-while-revalidate auf den warmen App-Cache.
|
|
*/
|
|
const PAGE_CACHE_CONTROL =
|
|
'public, max-age=0, s-maxage=30, stale-while-revalidate=300';
|
|
|
|
const ASSET_EXT_RE = /\.(ico|png|jpe?g|webp|gif|svg|css|js|mjs|map|woff2?|ttf|otf|txt|xml|json|pdf)$/i;
|
|
|
|
function isCacheablePath(pathname: string): boolean {
|
|
if (pathname.startsWith('/api/')) return false;
|
|
if (pathname.startsWith('/cms-images/')) return false;
|
|
if (pathname.startsWith('/_app/')) return false;
|
|
return true;
|
|
}
|
|
|
|
/** Pfade auf denen wir vor dem Resolve einen CMS-Health-Check machen. */
|
|
function shouldHealthCheck(pathname: string): boolean {
|
|
if (pathname.startsWith('/api/')) return false;
|
|
if (pathname.startsWith('/_app/')) return false;
|
|
if (pathname.startsWith('/cms-images/')) return false;
|
|
if (pathname.startsWith('/cms-files/')) return false;
|
|
if (pathname === '/sitemap.xml' || pathname === '/robots.txt') return false;
|
|
if (ASSET_EXT_RE.test(pathname)) return false;
|
|
return true;
|
|
}
|
|
|
|
function isMaintenanceSimulation(pathname: string, search: URLSearchParams): boolean {
|
|
if (pathname === '/maintenance' || pathname === '/maintenance/') return true;
|
|
if (search.has('cms_down')) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Legacy /blog/* + /p/* Pfade auf das neue /posts/* Schema mappen.
|
|
* Liefert Ziel-Pfad oder null. Permanente 301-Redirects → werden vom
|
|
* Browser/CDN gecacht, daher kein Render-Aufruf nötig.
|
|
*/
|
|
function legacyRedirectTarget(pathname: string): string | null {
|
|
if (pathname === '/blog' || pathname === '/blog/') return '/posts/';
|
|
const m1 = pathname.match(/^\/blog\/page\/([^/]+)\/?$/);
|
|
if (m1) return `/posts/page/${m1[1]}/`;
|
|
const m2 = pathname.match(/^\/blog\/tag\/([^/]+)\/?$/);
|
|
if (m2) return `/posts/tag/${m2[1]}/`;
|
|
const m3 = pathname.match(/^\/p\/([^/]+)\/?$/);
|
|
if (m3) return `/post/${m3[1]}/`;
|
|
return null;
|
|
}
|
|
|
|
export const handle: Handle = async ({ event, resolve }) => {
|
|
// Simulation: erlaubt manuelles Triggern der Maintenance-Seite ohne
|
|
// CMS abzuschalten. /maintenance oder ?cms_down=1.
|
|
if (isMaintenanceSimulation(event.url.pathname, event.url.searchParams)) {
|
|
return maintenanceResponse(200);
|
|
}
|
|
|
|
// CMS-verwaltete Redirects (Admin UI → /admin/plugins/redirects).
|
|
// Editor-Regeln gewinnen gegen hardcoded Legacy-Patterns; nur GET, sonst
|
|
// brechen POST-Forms beim Pfadwechsel.
|
|
if (event.request.method === 'GET') {
|
|
const cmsRedirect = await lookupRedirect(event.url.pathname);
|
|
if (cmsRedirect) {
|
|
const search = event.url.search;
|
|
const dest =
|
|
cmsRedirect.to.startsWith('http') || !search
|
|
? cmsRedirect.to
|
|
: `${cmsRedirect.to}${search}`;
|
|
return new Response(null, {
|
|
status: cmsRedirect.status,
|
|
headers: { location: dest },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Legacy-Pfade (/blog, /p) → /posts, /post. Vor Health-Check, weil
|
|
// SvelteKit-Routing/CMS-Calls dafür unnötig.
|
|
const redirectTo = legacyRedirectTarget(event.url.pathname);
|
|
if (redirectTo) {
|
|
const dest = `${redirectTo}${event.url.search}`;
|
|
return new Response(null, { status: 301, headers: { location: dest } });
|
|
}
|
|
|
|
// Echter Ausfall: vor dem ersten CMS-Call prüfen ob die API antwortet.
|
|
// Cached, daher kein Roundtrip pro Request.
|
|
if (event.request.method === 'GET' && shouldHealthCheck(event.url.pathname)) {
|
|
const ok = await isCmsAvailable();
|
|
if (!ok) return maintenanceResponse(503);
|
|
}
|
|
|
|
try {
|
|
event.locals.translations = await getNormalizedTranslations(
|
|
TRANSLATION_BUNDLE_SLUG,
|
|
{ locale: 'de' },
|
|
);
|
|
} catch (err) {
|
|
event.locals.translations = {};
|
|
logWarn('hooks.translations', err);
|
|
}
|
|
|
|
const response = await resolve(event, {
|
|
preload: ({ type, path }) => {
|
|
if (type === 'js' || type === 'css') return true;
|
|
if (type === 'font') {
|
|
return /inter-latin-(400|700)-normal\.[a-f0-9]*\.?woff2$/i.test(path);
|
|
}
|
|
return false;
|
|
},
|
|
});
|
|
|
|
const isPreview =
|
|
event.url.searchParams.has('preview') ||
|
|
event.url.searchParams.has('preview_draft');
|
|
const isPreviewDraft = event.url.searchParams.has('preview_draft');
|
|
|
|
// Vite-versioned assets: content-hashed filenames → safe to cache 1 year.
|
|
if (event.url.pathname.startsWith('/_app/immutable/')) {
|
|
response.headers.set('cache-control', 'public, max-age=31536000, immutable');
|
|
} else if (
|
|
event.request.method === 'GET' &&
|
|
isCacheablePath(event.url.pathname) &&
|
|
!isPreview &&
|
|
!response.headers.has('cache-control')
|
|
) {
|
|
response.headers.set('cache-control', PAGE_CACHE_CONTROL);
|
|
}
|
|
|
|
if (
|
|
event.request.method === 'GET' &&
|
|
!response.headers.has('x-robots-tag') &&
|
|
(response.headers.get('content-type') ?? '').includes('text/html')
|
|
) {
|
|
response.headers.set(
|
|
'x-robots-tag',
|
|
isPreview
|
|
? 'noindex, nofollow'
|
|
: 'index, follow, max-image-preview:large, max-snippet:-1',
|
|
);
|
|
}
|
|
|
|
// Live-preview: allow the admin iframe to embed this response. The admin
|
|
// origin is configured via PUBLIC_PREVIEW_PARENT_ORIGIN; when unset we fall
|
|
// back to wildcard (dev only). Outside live-preview we leave existing
|
|
// headers alone so production hardening (X-Frame-Options / CSP from Caddy)
|
|
// stays intact.
|
|
if (isPreviewDraft) {
|
|
const parent = (envPublic.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').trim();
|
|
response.headers.set(
|
|
'content-security-policy',
|
|
`frame-ancestors 'self' ${parent || '*'}`,
|
|
);
|
|
response.headers.delete('x-frame-options');
|
|
}
|
|
|
|
return response;
|
|
};
|