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