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
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}