import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; import { isProgrammingError } from "./errors.js"; import { logger } from "./logger.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; } interface ThemePolicyResponse { defaultLightThemeUri: string | null; defaultDarkThemeUri: string | null; allowUserChoice: boolean; availableThemes: Array<{ uri: string; cid?: string }>; } interface ThemeResponse { cid: string; tokens: Record; cssOverrides: string | null; fontUrls: string[] | 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 * 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light) * * Never throws — always returns a usable theme. */ export async function resolveTheme( appviewUrl: string, cookieHeader: string | undefined, colorSchemeHint: string | undefined ): Promise { const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint); // TODO: user preference (Theme Phase 4) // ── Step 1: Fetch theme 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 }; } // ── Step 2: Parse policy JSON ────────────────────────────────────────────── let policy: ThemePolicyResponse; try { policy = (await policyRes.json()) as ThemePolicyResponse; } catch { // SyntaxError from Response.json() is a data error, not a code bug — do not re-throw logger.error("Theme policy response contained invalid JSON — using fallback", { operation: "resolveTheme", url: `${appviewUrl}/api/theme-policy`, }); return { ...FALLBACK_THEME, colorScheme }; } // ── Step 3: 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 4: Fetch theme ──────────────────────────────────────────────────── 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: ThemeResponse; try { theme = (await themeRes.json()) as ThemeResponse; } 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 }; } return { tokens: theme.tokens as Record, cssOverrides: theme.cssOverrides ?? null, fontUrls: theme.fontUrls ?? null, colorScheme, }; }