da294010c8
- Changed site URLs in docker-compose and deployment configurations to remove "www" prefix for consistency. - Improved image handling by introducing warmPostCardImages function for pre-fetching images in posts and tags. - Enhanced PostCard component with responsive image sizes for better performance. - Updated layout to include robots meta tag and JSON-LD structured data for improved SEO. - Added loading state for images in TopBanner component to enhance user experience.
290 lines
8.0 KiB
TypeScript
290 lines
8.0 KiB
TypeScript
/**
|
||
* Server-only Bild-Helfer: Asset-Meta vom CMS, Blurhash-LQIP, Responsive-Srcset.
|
||
* Nur in `+page.server.ts` / `+layout.server.ts` importieren — nutzt sharp + blurhash + process.env.
|
||
*/
|
||
|
||
import { env } from '$env/dynamic/public';
|
||
import {
|
||
getCmsImageUrl,
|
||
type RustyImageTransformParams,
|
||
type CmsImageField,
|
||
} from './rusty-image';
|
||
|
||
function getCmsBaseUrl(): string {
|
||
return (env.PUBLIC_CMS_URL || process.env.PUBLIC_CMS_URL || 'http://localhost:3000').replace(
|
||
/\/$/,
|
||
'',
|
||
);
|
||
}
|
||
|
||
export interface AssetMeta {
|
||
width: number;
|
||
height: number;
|
||
hasAlpha: boolean;
|
||
mimeType: string;
|
||
blurhash?: string;
|
||
}
|
||
|
||
const metaCache = new Map<string, Promise<AssetMeta | null>>();
|
||
|
||
function toMetaEndpoint(url: string): string | null {
|
||
try {
|
||
const u = new URL(url);
|
||
const base = getCmsBaseUrl();
|
||
const baseHost = new URL(base).host;
|
||
if (u.host !== baseHost && !/^127\.0\.0\.1|localhost/.test(u.host)) {
|
||
return null;
|
||
}
|
||
u.searchParams.set('meta', 'true');
|
||
return `${base}${u.pathname}${u.search}`;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Holt AssetInfo vom CMS: `/api/assets/<path>?meta=true`.
|
||
* Cached per URL für die Prozess-Lebensdauer.
|
||
*/
|
||
export async function fetchAssetMeta(url: string): Promise<AssetMeta | null> {
|
||
const endpoint = toMetaEndpoint(url);
|
||
if (!endpoint) return null;
|
||
const cached = metaCache.get(endpoint);
|
||
if (cached) return cached;
|
||
const p = (async () => {
|
||
try {
|
||
const res = await fetch(endpoint);
|
||
if (!res.ok) return null;
|
||
const json = (await res.json()) as {
|
||
width?: number;
|
||
height?: number;
|
||
has_alpha?: boolean;
|
||
mime_type?: string;
|
||
blurhash?: string;
|
||
};
|
||
if (
|
||
typeof json?.width !== 'number' ||
|
||
typeof json?.height !== 'number' ||
|
||
json.width <= 0 ||
|
||
json.height <= 0
|
||
) {
|
||
return null;
|
||
}
|
||
return {
|
||
width: json.width,
|
||
height: json.height,
|
||
hasAlpha: Boolean(json.has_alpha),
|
||
mimeType: json.mime_type ?? 'image/jpeg',
|
||
...(typeof json.blurhash === 'string' && json.blurhash.trim()
|
||
? { blurhash: json.blurhash }
|
||
: {}),
|
||
} satisfies AssetMeta;
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();
|
||
metaCache.set(endpoint, p);
|
||
return p;
|
||
}
|
||
|
||
const blurhashCache = new Map<string, Promise<string | null>>();
|
||
|
||
/**
|
||
* Dekodiert Blurhash → 32×32 RGBA → JPEG Q40 → base64 data-URL.
|
||
* Liefert kompakten LQIP (~300–500 B) für CSS-`background-image`.
|
||
*/
|
||
export async function blurhashToDataUrl(
|
||
hash: string,
|
||
width = 32,
|
||
height = 32,
|
||
): Promise<string | null> {
|
||
const key = `${hash}|${width}x${height}`;
|
||
const cached = blurhashCache.get(key);
|
||
if (cached) return cached;
|
||
const p = (async () => {
|
||
try {
|
||
const [{ decode }, sharpMod] = await Promise.all([
|
||
import('blurhash'),
|
||
import('sharp'),
|
||
]);
|
||
const pixels = decode(hash, width, height);
|
||
const sharp = sharpMod.default;
|
||
const jpeg = await sharp(Buffer.from(pixels), {
|
||
raw: { width, height, channels: 4 },
|
||
})
|
||
.jpeg({ quality: 40 })
|
||
.toBuffer();
|
||
return `data:image/jpeg;base64,${jpeg.toString('base64')}`;
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();
|
||
blurhashCache.set(key, p);
|
||
return p;
|
||
}
|
||
|
||
export interface ResponsiveImage {
|
||
sources: { type: string; srcset: string }[];
|
||
fallback: string;
|
||
width: number;
|
||
height: number;
|
||
widths: number[];
|
||
lqip?: string;
|
||
}
|
||
|
||
export interface ResponsiveImageOptions {
|
||
widths?: number[];
|
||
ar?: string;
|
||
fit?: RustyImageTransformParams['fit'];
|
||
formats?: Array<'avif' | 'webp' | 'jpeg'>;
|
||
quality?: number;
|
||
focal?: CmsImageField['focal'];
|
||
lqip?: boolean;
|
||
}
|
||
|
||
const DEFAULT_WIDTHS = [640, 960, 1280, 1600, 1920, 2400];
|
||
const DEFAULT_FORMATS: Array<'avif' | 'webp' | 'jpeg'> = ['webp', 'jpeg'];
|
||
const MIME_FOR_FORMAT: Record<'avif' | 'webp' | 'jpeg', string> = {
|
||
avif: 'image/avif',
|
||
webp: 'image/webp',
|
||
jpeg: 'image/jpeg',
|
||
};
|
||
|
||
function parseAr(ar: string | undefined): number | null {
|
||
if (!ar) return null;
|
||
const m = ar.match(/^(\d+(?:\.\d+)?)\s*[/:x]\s*(\d+(?:\.\d+)?)$/);
|
||
if (!m) return null;
|
||
const w = Number(m[1]);
|
||
const h = Number(m[2]);
|
||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null;
|
||
return w / h;
|
||
}
|
||
|
||
/**
|
||
* Baut `<picture>`-Daten (AVIF/WebP/JPEG srcset + Fallback + LQIP).
|
||
* Breiten werden gegen die echte Bildbreite aus `fetchAssetMeta` geclampt (kein Upscaling).
|
||
* Alle URLs zeigen auf `/cms-images?…` (erst bei Client-Request wird gerendert & gecached).
|
||
*/
|
||
export async function buildResponsiveImage(
|
||
url: string,
|
||
opts: ResponsiveImageOptions = {},
|
||
): Promise<ResponsiveImage | null> {
|
||
const meta = await fetchAssetMeta(url);
|
||
|
||
const widthsRaw = (opts.widths ?? DEFAULT_WIDTHS).filter((w) => Number.isFinite(w) && w > 0);
|
||
if (widthsRaw.length === 0) return null;
|
||
|
||
const maxW = meta?.width ?? Math.max(...widthsRaw);
|
||
const widths = [...new Set(widthsRaw.map((w) => Math.min(w, maxW)).filter((w) => w > 0))].sort(
|
||
(a, b) => a - b,
|
||
);
|
||
if (widths.length === 0) return null;
|
||
|
||
const ar =
|
||
parseAr(opts.ar) ??
|
||
(meta?.width && meta?.height ? meta.width / meta.height : null);
|
||
|
||
const formats = opts.formats ?? DEFAULT_FORMATS;
|
||
const fit = opts.fit ?? 'cover';
|
||
const quality = opts.quality ?? 78;
|
||
|
||
const baseParams: RustyImageTransformParams = {
|
||
fit,
|
||
quality,
|
||
...(opts.ar ? { ar: opts.ar } : {}),
|
||
...(opts.focal
|
||
? { focalX: opts.focal.x, focalY: opts.focal.y }
|
||
: {}),
|
||
};
|
||
|
||
const sources = formats.map((fmt) => {
|
||
const srcset = widths
|
||
.map((w) => {
|
||
const u = getCmsImageUrl(url, { ...baseParams, width: w, format: fmt });
|
||
return `${u} ${w}w`;
|
||
})
|
||
.join(', ');
|
||
return { type: MIME_FOR_FORMAT[fmt], srcset };
|
||
});
|
||
|
||
const fallbackWidth = widths[widths.length - 1] ?? maxW;
|
||
const fallbackFormat: 'jpeg' | 'webp' = formats.includes('jpeg') ? 'jpeg' : 'webp';
|
||
const fallback = getCmsImageUrl(url, {
|
||
...baseParams,
|
||
width: fallbackWidth,
|
||
format: fallbackFormat,
|
||
});
|
||
|
||
const width = fallbackWidth;
|
||
const height = ar ? Math.round(width / ar) : meta?.height ?? Math.round(width * 0.5625);
|
||
|
||
const lqip =
|
||
opts.lqip !== false && meta?.blurhash
|
||
? ((await blurhashToDataUrl(meta.blurhash)) ?? undefined)
|
||
: undefined;
|
||
|
||
return {
|
||
sources,
|
||
fallback,
|
||
width,
|
||
height,
|
||
widths,
|
||
...(lqip ? { lqip } : {}),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Fire-and-forget Cache-Warmup: alle srcset-Varianten + Fallback parallel anrequesten.
|
||
* Nutzt SvelteKit's `event.fetch` für Self-Calls (keine echten HTTP-Roundtrips in Prod-SSR).
|
||
* Blockiert den Response NICHT — läuft im Hintergrund weiter.
|
||
*/
|
||
export function warmResponsiveImage(
|
||
responsive: ResponsiveImage | null,
|
||
fetchFn: typeof fetch,
|
||
): void {
|
||
if (!responsive) return;
|
||
const urls = new Set<string>();
|
||
urls.add(responsive.fallback);
|
||
for (const s of responsive.sources) {
|
||
for (const entry of s.srcset.split(',')) {
|
||
const url = entry.trim().split(/\s+/)[0];
|
||
if (url) urls.add(url);
|
||
}
|
||
}
|
||
for (const u of urls) {
|
||
void fetchFn(u).catch(() => {});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fire-and-forget Warmup für PostCard-Bilder (Grid: 400px base, 2x density, webp+jpeg).
|
||
* Spiegelt die URLs, die <RustyImage width={400} aspect="3/2"/> zur Laufzeit anfragt.
|
||
*/
|
||
export function warmPostCardImages(
|
||
posts: Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||
fetchFn: typeof fetch,
|
||
limit = 6,
|
||
): void {
|
||
const widths = [400, 800];
|
||
const formats: Array<'webp' | 'jpeg'> = ['webp', 'jpeg'];
|
||
for (const post of posts.slice(0, limit)) {
|
||
const raw = post._rawImageUrl;
|
||
if (!raw) continue;
|
||
const focal = post._imageFocal ?? undefined;
|
||
for (const w of widths) {
|
||
for (const format of formats) {
|
||
const url = getCmsImageUrl(raw, {
|
||
width: w,
|
||
height: Math.round((w / 3) * 2),
|
||
fit: 'cover',
|
||
format,
|
||
quality: 75,
|
||
ar: '3/2',
|
||
...(focal ? { focalX: focal.x, focalY: focal.y } : {}),
|
||
});
|
||
void fetchFn(url).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
}
|