The Hero server component awaited getMessages(locale) which called Directus with a 2-second timeout. On testing.dk0.dev (or when Directus is unreachable), this blocked the entire Hero render for ~2s → LCP 3.0s / 2320ms rendering delay. Changes: - Hero.tsx: remove getMessages() call entirely; use t() for all strings - messages/en.json + de.json: add hero.badge, hero.line1, hero.line2 keys - lib/i18n-loader.ts: invert lookup order — JSON first, Directus only as override for keys absent from JSON. Previously Directus was tried first for every key, causing ~49 parallel network requests per page load in HomePageServer (aboutT + projectsT + contactT + footerT translations). Now all JSON-backed keys return instantly without any network I/O. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
143 lines
3.8 KiB
TypeScript
143 lines
3.8 KiB
TypeScript
/**
|
||
* i18n Loader with Directus + JSON Fallback
|
||
* - Fetches from Directus first
|
||
* - Falls back to JSON files if not found
|
||
* - Caches results (5 min TTL)
|
||
*/
|
||
|
||
import { getMessage, getContentPage, ContentPage } from './directus';
|
||
import enMessages from '@/messages/en.json';
|
||
import deMessages from '@/messages/de.json';
|
||
|
||
const jsonFallback = { en: enMessages, de: deMessages };
|
||
|
||
// Simple in-memory cache
|
||
const cache = new Map<string, { value: unknown; expires: number }>();
|
||
|
||
function setCached(key: string, value: unknown, ttlSeconds = 300) {
|
||
cache.set(key, { value, expires: Date.now() + ttlSeconds * 1000 });
|
||
}
|
||
|
||
function getCached(key: string): unknown | null {
|
||
const hit = cache.get(key);
|
||
if (!hit) return null;
|
||
if (Date.now() > hit.expires) {
|
||
cache.delete(key);
|
||
return null;
|
||
}
|
||
return hit.value;
|
||
}
|
||
|
||
/**
|
||
* Get a localized message by key.
|
||
* Tries: JSON (requested locale) → JSON (EN) → Directus (requested locale) → Directus (EN)
|
||
*
|
||
* JSON is checked first so that translation-heavy server components never wait on
|
||
* a Directus network round-trip for keys that already exist in the message files.
|
||
* Directus is only queried when the key is absent from JSON (i.e. CMS-only content).
|
||
*/
|
||
export async function getLocalizedMessage(
|
||
key: string,
|
||
locale: string
|
||
): Promise<string> {
|
||
const cacheKey = `msg:${key}:${locale}`;
|
||
const cached = getCached(cacheKey);
|
||
if (cached !== null) return cached as string;
|
||
|
||
const normalizedLocale = locale.startsWith('de') ? 'de' : 'en';
|
||
|
||
// 1) JSON – requested locale
|
||
const jsonValue = getNestedValue(jsonFallback[normalizedLocale as 'en' | 'de'], key);
|
||
if (jsonValue) {
|
||
setCached(cacheKey, jsonValue);
|
||
return jsonValue;
|
||
}
|
||
|
||
// 2) JSON – EN fallback
|
||
if (normalizedLocale !== 'en') {
|
||
const jsonValueEn = getNestedValue(jsonFallback['en'], key);
|
||
if (jsonValueEn) {
|
||
setCached(cacheKey, jsonValueEn);
|
||
return jsonValueEn;
|
||
}
|
||
}
|
||
|
||
// 3) Directus – only for keys missing from JSON (CMS-only content)
|
||
const dbValue = await getMessage(key, locale);
|
||
if (dbValue) {
|
||
setCached(cacheKey, dbValue);
|
||
return dbValue;
|
||
}
|
||
|
||
// 4) Directus – EN fallback
|
||
if (locale !== 'en') {
|
||
const dbValueEn = await getMessage(key, 'en');
|
||
if (dbValueEn) {
|
||
setCached(cacheKey, dbValueEn);
|
||
return dbValueEn;
|
||
}
|
||
}
|
||
|
||
// 5) Return the key itself as last resort
|
||
return key;
|
||
}
|
||
|
||
/**
|
||
* Get a localized content page by slug
|
||
* Tries: Directus (requested locale) → Directus (EN)
|
||
*/
|
||
export async function getLocalizedContent(
|
||
slug: string,
|
||
locale: string
|
||
): Promise<ContentPage | null> {
|
||
const cacheKey = `page:${slug}:${locale}`;
|
||
const cached = getCached(cacheKey);
|
||
if (cached !== null) return cached as ContentPage;
|
||
if (cache.has(cacheKey)) return null; // Already checked, not found
|
||
|
||
// Try Directus with requested locale
|
||
const dbPage = await getContentPage(slug, locale);
|
||
if (dbPage) {
|
||
setCached(cacheKey, dbPage);
|
||
return dbPage;
|
||
}
|
||
|
||
// Fallback to EN in Directus
|
||
if (locale !== 'en') {
|
||
const dbPageEn = await getContentPage(slug, 'en');
|
||
if (dbPageEn) {
|
||
setCached(cacheKey, dbPageEn);
|
||
return dbPageEn;
|
||
}
|
||
}
|
||
|
||
// Not found
|
||
setCached(cacheKey, null);
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Helper: Get nested value from object
|
||
* Example: "nav.home" → obj.nav.home
|
||
*/
|
||
function getNestedValue(obj: Record<string, unknown>, path: string): string | null {
|
||
const keys = path.split('.');
|
||
let value: unknown = obj;
|
||
for (const key of keys) {
|
||
if (value && typeof value === 'object' && key in value) {
|
||
value = (value as Record<string, unknown>)[key];
|
||
} else {
|
||
return null;
|
||
}
|
||
if (value === undefined) return null;
|
||
}
|
||
return typeof value === 'string' ? value : null;
|
||
}
|
||
|
||
/**
|
||
* Clear cache (useful for webhooks/revalidation)
|
||
*/
|
||
export function clearI18nCache() {
|
||
cache.clear();
|
||
}
|