import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; import neobrutalDark from "../styles/presets/neobrutal-dark.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; /** Returns a fallback theme with the correct tokens for the given color scheme. */ export function fallbackForScheme(colorScheme: "light" | "dark"): ResolvedTheme { return { tokens: colorScheme === "dark" ? (neobrutalDark as Record) : (neobrutalLight as Record), cssOverrides: null, fontUrls: [ "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap", ], colorScheme, }; } /** * 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; } /** * Reads the user's theme preference cookie for the active color scheme and * validates it against the policy's availableThemes list. * Returns the validated AT URI, or null if absent, stale, or choice not allowed. */ export function resolveUserThemePreference( cookieHeader: string | undefined, colorScheme: "light" | "dark", availableThemes: Array<{ uri: string }>, allowUserChoice: boolean ): string | null { if (!allowUserChoice) return null; const cookieName = colorScheme === "light" ? "atbb-light-theme" : "atbb-dark-theme"; const match = cookieHeader?.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]+)`)); if (!match) return null; const uri = match[1].trim(); return availableThemes.some((t) => t.uri === uri) ? uri : null; } /** * Resolves which theme to render for a request using the waterfall: * 1. Detect color scheme — atbb-color-scheme cookie or Sec-CH hint * 2. Fetch theme policy — GET /api/theme-policy (cached in memory) * 3. User preference — atbb-light-theme / atbb-dark-theme cookie (validated against policy) * 4. Forum default — defaultLightThemeUri / defaultDarkThemeUri from policy * 5. Hardcoded fallback — neobrutal-light or neobrutal-dark per color scheme * * 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); // ── Policy: fetch or restore from cache ──────────────────────────────────── 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 fallbackForScheme(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 fallbackForScheme(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 fallbackForScheme(colorScheme); } } // ── URI resolution: user preference → forum default ─────────────────────── const userPreferenceUri = resolveUserThemePreference( cookieHeader, colorScheme, policy.availableThemes, policy.allowUserChoice ); const defaultUri = userPreferenceUri ?? (colorScheme === "dark" ? policy.defaultDarkThemeUri : policy.defaultLightThemeUri); if (!defaultUri) return fallbackForScheme(colorScheme); const rkey = parseRkeyFromUri(defaultUri); if (!rkey || !/^[a-z0-9-]+$/i.test(rkey)) return fallbackForScheme(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; // ── Theme data: restore from cache if available ──────────────────────────── 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, }; } } // ── Theme data: fetch 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 fallbackForScheme(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 fallbackForScheme(colorScheme); } // ── Theme data: parse response 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 fallbackForScheme(colorScheme); } // ── 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 fallbackForScheme(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, }; }