import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; import { isProgrammingError } from "./errors.js"; import { logger } from "./logger.js"; import { ThemeCache, type CachedPolicy, type CachedTheme } from "./theme-cache.js"; export type ResolvedTheme = { tokens: Record; cssOverrides: string | null; fontUrls: string[] | null; colorScheme: "light" | "dark"; }; /** Hono app environment type — used by middleware and all route factories. */ export type WebAppEnv = { Variables: { theme: ResolvedTheme }; }; /** Hardcoded fallback used when theme policy is missing or resolution fails. */ export const FALLBACK_THEME: ResolvedTheme = Object.freeze({ tokens: neobrutalLight as Record, cssOverrides: null, fontUrls: Object.freeze([ "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap", ]), colorScheme: "light", } as const) as ResolvedTheme; /** * Detects the user's preferred color scheme. * Priority: atbb-color-scheme cookie → Sec-CH-Prefers-Color-Scheme hint → "light". */ export function detectColorScheme( cookieHeader: string | undefined, hint: string | undefined ): "light" | "dark" { const match = cookieHeader?.match(/(?:^|;\s*)atbb-color-scheme=(light|dark)/); if (match) return match[1] as "light" | "dark"; if (hint === "dark") return "dark"; return "light"; } /** * Extracts the rkey segment from an AT URI. * Example: "at://did:plc:abc/space.atbb.forum.theme/rkey123" → "rkey123" */ export function parseRkeyFromUri(atUri: string): string | null { // Format: at://// // Split gives: ["at:", "", "", "", ""] const parts = atUri.split("/"); if (parts.length < 5) return null; return parts[4] ?? null; } /** * Resolves which theme to render for a request using the waterfall: * 1. User preference — not yet implemented (TODO: Theme Phase 4) * 2. Color scheme default — atbb-color-scheme cookie or Sec-CH hint * 3. Forum default — fetched from GET /api/theme-policy (cached in memory) * 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light) * * Pass a ThemeCache instance to enable in-memory TTL caching of policy and * theme data. The cache is checked before each network request and populated * after a successful fetch + CID validation. * * Never throws — always returns a usable theme. */ export async function resolveTheme( appviewUrl: string, cookieHeader: string | undefined, colorSchemeHint: string | undefined, cache?: ThemeCache ): Promise { const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint); // TODO: user preference (Theme Phase 4) // ── Step 1: Get theme policy (from cache or AppView) ─────────────────────── let policy: CachedPolicy | null = cache?.getPolicy() ?? null; if (!policy) { let policyRes: Response; try { policyRes = await fetch(`${appviewUrl}/api/theme-policy`); if (!policyRes.ok) { logger.warn("Theme policy fetch returned non-ok status — using fallback", { operation: "resolveTheme", status: policyRes.status, url: `${appviewUrl}/api/theme-policy`, }); return { ...FALLBACK_THEME, colorScheme }; } } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Theme policy fetch failed — using fallback", { operation: "resolveTheme", error: error instanceof Error ? error.message : String(error), }); return { ...FALLBACK_THEME, colorScheme }; } try { // SyntaxError from Response.json() is a data error, not a code bug — do not re-throw policy = (await policyRes.json()) as CachedPolicy; cache?.setPolicy(policy); } catch { logger.error("Theme policy response contained invalid JSON — using fallback", { operation: "resolveTheme", url: `${appviewUrl}/api/theme-policy`, }); return { ...FALLBACK_THEME, colorScheme }; } } // ── Step 2: Extract default theme URI and rkey ───────────────────────────── const defaultUri = colorScheme === "dark" ? policy.defaultDarkThemeUri : policy.defaultLightThemeUri; if (!defaultUri) return { ...FALLBACK_THEME, colorScheme }; const rkey = parseRkeyFromUri(defaultUri); if (!rkey || !/^[a-z0-9-]+$/i.test(rkey)) return { ...FALLBACK_THEME, colorScheme }; const matchingTheme = policy.availableThemes.find((t) => t.uri === defaultUri); if (!matchingTheme) { logger.warn("Theme URI not in availableThemes — skipping CID check", { operation: "resolveTheme", themeUri: defaultUri, }); } // cid may be absent for live refs (e.g. canonical atbb.space presets) — that is expected const expectedCid = matchingTheme?.cid ?? null; // ── Step 3: Get theme (from cache or AppView) ────────────────────────────── const cachedTheme: CachedTheme | null = cache?.getTheme(defaultUri, colorScheme) ?? null; if (cachedTheme !== null) { // CID check on the cached entry — guards against stale cache when the policy // refreshed (TTL expired) with a new CID while the theme entry is still live. if (expectedCid && cachedTheme.cid !== expectedCid) { logger.warn("Cached theme has stale CID — evicting and fetching fresh from AppView", { operation: "resolveTheme", expectedCid, cachedCid: cachedTheme.cid, themeUri: defaultUri, }); // Evict the stale entry so that if the fresh fetch also fails, // the next request doesn't re-enter this loop indefinitely. cache?.deleteTheme(defaultUri, colorScheme); // Fall through to fresh fetch below } else { // Cache hit — CID matches (or live ref with no expected CID) return { tokens: cachedTheme.tokens, cssOverrides: cachedTheme.cssOverrides ?? null, fontUrls: cachedTheme.fontUrls ?? null, colorScheme, }; } } // ── Step 4: Fetch theme from AppView ────────────────────────────────────── let themeRes: Response; try { themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); if (!themeRes.ok) { logger.warn("Theme fetch returned non-ok status — using fallback", { operation: "resolveTheme", status: themeRes.status, rkey, themeUri: defaultUri, }); return { ...FALLBACK_THEME, colorScheme }; } } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Theme fetch failed — using fallback", { operation: "resolveTheme", rkey, error: error instanceof Error ? error.message : String(error), }); return { ...FALLBACK_THEME, colorScheme }; } // ── Step 5: Parse theme JSON ─────────────────────────────────────────────── let theme: CachedTheme; try { theme = (await themeRes.json()) as CachedTheme; } catch { logger.error("Theme response contained invalid JSON — using fallback", { operation: "resolveTheme", rkey, themeUri: defaultUri, }); return { ...FALLBACK_THEME, colorScheme }; } // ── Step 6: CID integrity check ──────────────────────────────────────────── if (expectedCid && theme.cid !== expectedCid) { logger.warn("Theme CID mismatch — using hardcoded fallback", { operation: "resolveTheme", expectedCid, actualCid: theme.cid, themeUri: defaultUri, }); return { ...FALLBACK_THEME, colorScheme }; } // Populate cache only after successful validation cache?.setTheme(defaultUri, colorScheme, theme); return { tokens: theme.tokens, cssOverrides: theme.cssOverrides ?? null, fontUrls: theme.fontUrls ?? null, colorScheme, }; }