WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at user-theme-preferences 257 lines 9.8 kB view raw
1import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; 2import neobrutalDark from "../styles/presets/neobrutal-dark.json" with { type: "json" }; 3import { isProgrammingError } from "./errors.js"; 4import { logger } from "./logger.js"; 5import { ThemeCache, type CachedPolicy, type CachedTheme } from "./theme-cache.js"; 6 7export type ResolvedTheme = { 8 tokens: Record<string, string>; 9 cssOverrides: string | null; 10 fontUrls: string[] | null; 11 colorScheme: "light" | "dark"; 12}; 13 14/** Hono app environment type — used by middleware and all route factories. */ 15export type WebAppEnv = { 16 Variables: { theme: ResolvedTheme }; 17}; 18 19/** Hardcoded fallback used when theme policy is missing or resolution fails. */ 20export const FALLBACK_THEME: ResolvedTheme = Object.freeze({ 21 tokens: neobrutalLight as Record<string, string>, 22 cssOverrides: null, 23 fontUrls: Object.freeze([ 24 "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap", 25 ]), 26 colorScheme: "light", 27} as const) as ResolvedTheme; 28 29/** Returns a fallback theme with the correct tokens for the given color scheme. */ 30export function fallbackForScheme(colorScheme: "light" | "dark"): ResolvedTheme { 31 return { 32 tokens: 33 colorScheme === "dark" 34 ? (neobrutalDark as Record<string, string>) 35 : (neobrutalLight as Record<string, string>), 36 cssOverrides: null, 37 fontUrls: [ 38 "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap", 39 ], 40 colorScheme, 41 }; 42} 43 44/** 45 * Detects the user's preferred color scheme. 46 * Priority: atbb-color-scheme cookie → Sec-CH-Prefers-Color-Scheme hint → "light". 47 */ 48export function detectColorScheme( 49 cookieHeader: string | undefined, 50 hint: string | undefined 51): "light" | "dark" { 52 const match = cookieHeader?.match(/(?:^|;\s*)atbb-color-scheme=(light|dark)/); 53 if (match) return match[1] as "light" | "dark"; 54 if (hint === "dark") return "dark"; 55 return "light"; 56} 57 58/** 59 * Extracts the rkey segment from an AT URI. 60 * Example: "at://did:plc:abc/space.atbb.forum.theme/rkey123" → "rkey123" 61 */ 62export function parseRkeyFromUri(atUri: string): string | null { 63 // Format: at://<did>/<collection>/<rkey> 64 // Split gives: ["at:", "", "<did>", "<collection>", "<rkey>"] 65 const parts = atUri.split("/"); 66 if (parts.length < 5) return null; 67 return parts[4] ?? null; 68} 69 70/** 71 * Reads the user's theme preference cookie for the active color scheme and 72 * validates it against the policy's availableThemes list. 73 * Returns the validated AT URI, or null if absent, stale, or choice not allowed. 74 */ 75export function resolveUserThemePreference( 76 cookieHeader: string | undefined, 77 colorScheme: "light" | "dark", 78 availableThemes: Array<{ uri: string }>, 79 allowUserChoice: boolean 80): string | null { 81 if (!allowUserChoice) return null; 82 const cookieName = colorScheme === "light" ? "atbb-light-theme" : "atbb-dark-theme"; 83 const match = cookieHeader?.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]+)`)); 84 if (!match) return null; 85 const uri = match[1].trim(); 86 return availableThemes.some((t) => t.uri === uri) ? uri : null; 87} 88 89/** 90 * Resolves which theme to render for a request using the waterfall: 91 * 1. Detect color scheme — atbb-color-scheme cookie or Sec-CH hint 92 * 2. Fetch theme policy — GET /api/theme-policy (cached in memory) 93 * 3. User preference — atbb-light-theme / atbb-dark-theme cookie (validated against policy) 94 * 4. Forum default — defaultLightThemeUri / defaultDarkThemeUri from policy 95 * 5. Hardcoded fallback — neobrutal-light or neobrutal-dark per color scheme 96 * 97 * Pass a ThemeCache instance to enable in-memory TTL caching of policy and 98 * theme data. The cache is checked before each network request and populated 99 * after a successful fetch + CID validation. 100 * 101 * Never throws — always returns a usable theme. 102 */ 103export async function resolveTheme( 104 appviewUrl: string, 105 cookieHeader: string | undefined, 106 colorSchemeHint: string | undefined, 107 cache?: ThemeCache 108): Promise<ResolvedTheme> { 109 const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint); 110 111 // ── Policy: fetch or restore from cache ──────────────────────────────────── 112 let policy: CachedPolicy | null = cache?.getPolicy() ?? null; 113 114 if (!policy) { 115 let policyRes: Response; 116 try { 117 policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 118 if (!policyRes.ok) { 119 logger.warn("Theme policy fetch returned non-ok status — using fallback", { 120 operation: "resolveTheme", 121 status: policyRes.status, 122 url: `${appviewUrl}/api/theme-policy`, 123 }); 124 return fallbackForScheme(colorScheme); 125 } 126 } catch (error) { 127 if (isProgrammingError(error)) throw error; 128 logger.error("Theme policy fetch failed — using fallback", { 129 operation: "resolveTheme", 130 error: error instanceof Error ? error.message : String(error), 131 }); 132 return fallbackForScheme(colorScheme); 133 } 134 135 try { 136 // SyntaxError from Response.json() is a data error, not a code bug — do not re-throw 137 policy = (await policyRes.json()) as CachedPolicy; 138 cache?.setPolicy(policy); 139 } catch { 140 logger.error("Theme policy response contained invalid JSON — using fallback", { 141 operation: "resolveTheme", 142 url: `${appviewUrl}/api/theme-policy`, 143 }); 144 return fallbackForScheme(colorScheme); 145 } 146 } 147 148 // ── URI resolution: user preference → forum default ─────────────────────── 149 const userPreferenceUri = resolveUserThemePreference( 150 cookieHeader, 151 colorScheme, 152 policy.availableThemes, 153 policy.allowUserChoice 154 ); 155 const defaultUri = 156 userPreferenceUri ?? 157 (colorScheme === "dark" ? policy.defaultDarkThemeUri : policy.defaultLightThemeUri); 158 if (!defaultUri) return fallbackForScheme(colorScheme); 159 160 const rkey = parseRkeyFromUri(defaultUri); 161 if (!rkey || !/^[a-z0-9-]+$/i.test(rkey)) return fallbackForScheme(colorScheme); 162 163 const matchingTheme = policy.availableThemes.find((t) => t.uri === defaultUri); 164 if (!matchingTheme) { 165 logger.warn("Theme URI not in availableThemes — skipping CID check", { 166 operation: "resolveTheme", 167 themeUri: defaultUri, 168 }); 169 } 170 // cid may be absent for live refs (e.g. canonical atbb.space presets) — that is expected 171 const expectedCid = matchingTheme?.cid ?? null; 172 173 // ── Theme data: restore from cache if available ──────────────────────────── 174 const cachedTheme: CachedTheme | null = cache?.getTheme(defaultUri, colorScheme) ?? null; 175 176 if (cachedTheme !== null) { 177 // CID check on the cached entry — guards against stale cache when the policy 178 // refreshed (TTL expired) with a new CID while the theme entry is still live. 179 if (expectedCid && cachedTheme.cid !== expectedCid) { 180 logger.warn("Cached theme has stale CID — evicting and fetching fresh from AppView", { 181 operation: "resolveTheme", 182 expectedCid, 183 cachedCid: cachedTheme.cid, 184 themeUri: defaultUri, 185 }); 186 // Evict the stale entry so that if the fresh fetch also fails, 187 // the next request doesn't re-enter this loop indefinitely. 188 cache?.deleteTheme(defaultUri, colorScheme); 189 // Fall through to fresh fetch below 190 } else { 191 // Cache hit — CID matches (or live ref with no expected CID) 192 return { 193 tokens: cachedTheme.tokens, 194 cssOverrides: cachedTheme.cssOverrides ?? null, 195 fontUrls: cachedTheme.fontUrls ?? null, 196 colorScheme, 197 }; 198 } 199 } 200 201 // ── Theme data: fetch from AppView ──────────────────────────────────────── 202 let themeRes: Response; 203 try { 204 themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); 205 if (!themeRes.ok) { 206 logger.warn("Theme fetch returned non-ok status — using fallback", { 207 operation: "resolveTheme", 208 status: themeRes.status, 209 rkey, 210 themeUri: defaultUri, 211 }); 212 return fallbackForScheme(colorScheme); 213 } 214 } catch (error) { 215 if (isProgrammingError(error)) throw error; 216 logger.error("Theme fetch failed — using fallback", { 217 operation: "resolveTheme", 218 rkey, 219 error: error instanceof Error ? error.message : String(error), 220 }); 221 return fallbackForScheme(colorScheme); 222 } 223 224 // ── Theme data: parse response JSON ─────────────────────────────────────── 225 let theme: CachedTheme; 226 try { 227 theme = (await themeRes.json()) as CachedTheme; 228 } catch { 229 logger.error("Theme response contained invalid JSON — using fallback", { 230 operation: "resolveTheme", 231 rkey, 232 themeUri: defaultUri, 233 }); 234 return fallbackForScheme(colorScheme); 235 } 236 237 // ── CID integrity check ──────────────────────────────────────────────────── 238 if (expectedCid && theme.cid !== expectedCid) { 239 logger.warn("Theme CID mismatch — using hardcoded fallback", { 240 operation: "resolveTheme", 241 expectedCid, 242 actualCid: theme.cid, 243 themeUri: defaultUri, 244 }); 245 return fallbackForScheme(colorScheme); 246 } 247 248 // Populate cache only after successful validation 249 cache?.setTheme(defaultUri, colorScheme, theme); 250 251 return { 252 tokens: theme.tokens, 253 cssOverrides: theme.cssOverrides ?? null, 254 fontUrls: theme.fontUrls ?? null, 255 colorScheme, 256 }; 257}