@rangojs/router 0.0.0-experimental.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +7 -0
- package/README.md +19 -0
- package/dist/vite/index.js +1298 -0
- package/package.json +140 -0
- package/skills/caching/SKILL.md +319 -0
- package/skills/document-cache/SKILL.md +152 -0
- package/skills/hooks/SKILL.md +359 -0
- package/skills/intercept/SKILL.md +292 -0
- package/skills/layout/SKILL.md +216 -0
- package/skills/loader/SKILL.md +365 -0
- package/skills/middleware/SKILL.md +442 -0
- package/skills/parallel/SKILL.md +255 -0
- package/skills/route/SKILL.md +141 -0
- package/skills/router-setup/SKILL.md +403 -0
- package/skills/theme/SKILL.md +54 -0
- package/skills/typesafety/SKILL.md +352 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/component-utils.test.ts +76 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/__tests__/urls.test.tsx +436 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +893 -0
- package/src/browser/navigation-client.ts +162 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +559 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +275 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-href.tsx +208 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +164 -0
- package/src/browser/rsc-router.tsx +353 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +464 -0
- package/src/cache/__tests__/document-cache.test.ts +522 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +428 -0
- package/src/cache/cf/cf-cache-store.ts +428 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/document-cache.ts +340 -0
- package/src/cache/index.ts +58 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +387 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +621 -0
- package/src/component-utils.ts +76 -0
- package/src/components/DefaultDocument.tsx +23 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +193 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href-context.ts +33 -0
- package/src/href.ts +177 -0
- package/src/index.rsc.ts +79 -0
- package/src/index.ts +87 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1371 -0
- package/src/route-map-builder.ts +146 -0
- package/src/route-types.ts +198 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +158 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +138 -0
- package/src/router/match-context.ts +264 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +266 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +214 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +272 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3876 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +1060 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +237 -0
- package/src/segment-system.tsx +456 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +417 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +146 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +234 -0
- package/src/theme/ThemeProvider.tsx +291 -0
- package/src/theme/ThemeScript.tsx +61 -0
- package/src/theme/__tests__/theme.test.ts +120 -0
- package/src/theme/constants.ts +55 -0
- package/src/theme/index.ts +58 -0
- package/src/theme/theme-context.ts +70 -0
- package/src/theme/theme-script.ts +152 -0
- package/src/theme/types.ts +182 -0
- package/src/theme/use-theme.ts +44 -0
- package/src/types.ts +1561 -0
- package/src/urls.ts +726 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +787 -0
- package/src/vite/package-resolution.ts +125 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +109 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ThemeProvider - Client component that provides theme state and management.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Syncs theme to cookie/localStorage
|
|
8
|
+
* - Detects system preference changes
|
|
9
|
+
* - Cross-tab synchronization via storage events
|
|
10
|
+
* - Updates HTML element attribute when theme changes
|
|
11
|
+
* - Handles SSR hydration by deferring system theme detection
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
|
15
|
+
import { ThemeContext } from "./theme-context.js";
|
|
16
|
+
import type {
|
|
17
|
+
ResolvedTheme,
|
|
18
|
+
ResolvedThemeConfig,
|
|
19
|
+
Theme,
|
|
20
|
+
ThemeContextValue,
|
|
21
|
+
ThemeProviderProps,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
import { THEME_COOKIE } from "./constants.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get system preference for color scheme
|
|
27
|
+
*/
|
|
28
|
+
function getSystemTheme(): ResolvedTheme {
|
|
29
|
+
if (typeof window !== "undefined" && window.matchMedia) {
|
|
30
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
31
|
+
? "dark"
|
|
32
|
+
: "light";
|
|
33
|
+
}
|
|
34
|
+
return "light";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read theme from cookie
|
|
39
|
+
*/
|
|
40
|
+
function readThemeFromCookie(storageKey: string): string | null {
|
|
41
|
+
if (typeof document === "undefined") return null;
|
|
42
|
+
|
|
43
|
+
const cookies = document.cookie.split(";");
|
|
44
|
+
for (const cookie of cookies) {
|
|
45
|
+
const [name, ...rest] = cookie.trim().split("=");
|
|
46
|
+
if (name === storageKey) {
|
|
47
|
+
return decodeURIComponent(rest.join("="));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read theme from localStorage
|
|
55
|
+
*/
|
|
56
|
+
function readThemeFromStorage(storageKey: string): string | null {
|
|
57
|
+
if (typeof localStorage === "undefined") return null;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
return localStorage.getItem(storageKey);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write theme to cookie
|
|
68
|
+
*/
|
|
69
|
+
function writeThemeToCookie(storageKey: string, theme: Theme): void {
|
|
70
|
+
if (typeof document === "undefined") return;
|
|
71
|
+
|
|
72
|
+
const value = encodeURIComponent(theme);
|
|
73
|
+
const cookie = `${storageKey}=${value}; Path=${THEME_COOKIE.path}; Max-Age=${THEME_COOKIE.maxAge}; SameSite=${THEME_COOKIE.sameSite}`;
|
|
74
|
+
document.cookie = cookie;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Write theme to localStorage
|
|
79
|
+
*/
|
|
80
|
+
function writeThemeToStorage(storageKey: string, theme: Theme): void {
|
|
81
|
+
if (typeof localStorage === "undefined") return;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
localStorage.setItem(storageKey, theme);
|
|
85
|
+
} catch {
|
|
86
|
+
// localStorage might be disabled or full
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Apply theme to HTML element
|
|
92
|
+
*/
|
|
93
|
+
function applyThemeToDocument(
|
|
94
|
+
theme: Theme,
|
|
95
|
+
config: ResolvedThemeConfig
|
|
96
|
+
): void {
|
|
97
|
+
if (typeof document === "undefined") return;
|
|
98
|
+
|
|
99
|
+
const resolved = theme === "system" && config.enableSystem
|
|
100
|
+
? getSystemTheme()
|
|
101
|
+
: (theme as ResolvedTheme);
|
|
102
|
+
|
|
103
|
+
const value = config.value[resolved] || resolved;
|
|
104
|
+
const el = document.documentElement;
|
|
105
|
+
|
|
106
|
+
// Apply attribute
|
|
107
|
+
if (config.attribute === "class") {
|
|
108
|
+
// Remove all theme classes
|
|
109
|
+
for (const t of config.themes) {
|
|
110
|
+
const v = config.value[t] || t;
|
|
111
|
+
el.classList.remove(v);
|
|
112
|
+
}
|
|
113
|
+
// Add current theme class
|
|
114
|
+
el.classList.add(value);
|
|
115
|
+
} else {
|
|
116
|
+
el.setAttribute(config.attribute, value);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Set color-scheme for native dark mode support
|
|
120
|
+
if (config.enableColorScheme) {
|
|
121
|
+
el.style.colorScheme = resolved;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get the resolved stored theme (validated against available themes)
|
|
127
|
+
*/
|
|
128
|
+
function getStoredTheme(config: ResolvedThemeConfig): Theme {
|
|
129
|
+
const { storageKey, themes, defaultTheme, enableSystem } = config;
|
|
130
|
+
|
|
131
|
+
// Try cookie first (for SSR consistency)
|
|
132
|
+
let stored = readThemeFromCookie(storageKey);
|
|
133
|
+
|
|
134
|
+
// Fall back to localStorage
|
|
135
|
+
if (!stored) {
|
|
136
|
+
stored = readThemeFromStorage(storageKey);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Validate stored value
|
|
140
|
+
if (stored) {
|
|
141
|
+
if (stored === "system" && enableSystem) {
|
|
142
|
+
return "system";
|
|
143
|
+
}
|
|
144
|
+
if (themes.includes(stored)) {
|
|
145
|
+
return stored as Theme;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return defaultTheme;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* ThemeProvider component
|
|
154
|
+
*
|
|
155
|
+
* Provides theme state to the component tree via context.
|
|
156
|
+
* Handles theme persistence, system preference detection, and cross-tab sync.
|
|
157
|
+
*/
|
|
158
|
+
export function ThemeProvider({
|
|
159
|
+
config,
|
|
160
|
+
initialTheme,
|
|
161
|
+
children,
|
|
162
|
+
}: ThemeProviderProps): React.ReactNode {
|
|
163
|
+
// Track mount state to avoid hydration mismatches
|
|
164
|
+
// During SSR and initial hydration, mounted is false
|
|
165
|
+
const [mounted, setMounted] = useState(false);
|
|
166
|
+
|
|
167
|
+
// Initialize theme from prop, storage, or default
|
|
168
|
+
const [theme, setThemeState] = useState<Theme>(() => {
|
|
169
|
+
if (initialTheme) return initialTheme;
|
|
170
|
+
if (typeof window === "undefined") return config.defaultTheme;
|
|
171
|
+
return getStoredTheme(config);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Track system preference - use stable default during SSR
|
|
175
|
+
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>("light");
|
|
176
|
+
|
|
177
|
+
// Set mounted after hydration and detect actual system theme
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
setMounted(true);
|
|
180
|
+
setSystemTheme(getSystemTheme());
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
// Set theme and persist to storage
|
|
184
|
+
const setTheme = useCallback(
|
|
185
|
+
(newTheme: Theme) => {
|
|
186
|
+
setThemeState(newTheme);
|
|
187
|
+
writeThemeToCookie(config.storageKey, newTheme);
|
|
188
|
+
writeThemeToStorage(config.storageKey, newTheme);
|
|
189
|
+
applyThemeToDocument(newTheme, config);
|
|
190
|
+
},
|
|
191
|
+
[config]
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Listen for system preference changes
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!config.enableSystem) return;
|
|
197
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
198
|
+
|
|
199
|
+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
200
|
+
|
|
201
|
+
const handleChange = (e: MediaQueryListEvent) => {
|
|
202
|
+
const newSystemTheme = e.matches ? "dark" : "light";
|
|
203
|
+
setSystemTheme(newSystemTheme);
|
|
204
|
+
|
|
205
|
+
// If current theme is "system", re-apply to update document
|
|
206
|
+
if (theme === "system") {
|
|
207
|
+
applyThemeToDocument("system", config);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Modern browsers
|
|
212
|
+
mediaQuery.addEventListener("change", handleChange);
|
|
213
|
+
|
|
214
|
+
return () => {
|
|
215
|
+
mediaQuery.removeEventListener("change", handleChange);
|
|
216
|
+
};
|
|
217
|
+
}, [config, theme]);
|
|
218
|
+
|
|
219
|
+
// Cross-tab synchronization via localStorage storage event
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (typeof window === "undefined") return;
|
|
222
|
+
|
|
223
|
+
const handleStorageChange = (e: StorageEvent) => {
|
|
224
|
+
if (e.key !== config.storageKey) return;
|
|
225
|
+
|
|
226
|
+
const newTheme = e.newValue;
|
|
227
|
+
if (!newTheme) return;
|
|
228
|
+
|
|
229
|
+
// Validate and apply
|
|
230
|
+
if (
|
|
231
|
+
newTheme === "system" ||
|
|
232
|
+
config.themes.includes(newTheme)
|
|
233
|
+
) {
|
|
234
|
+
setThemeState(newTheme as Theme);
|
|
235
|
+
applyThemeToDocument(newTheme as Theme, config);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
window.addEventListener("storage", handleStorageChange);
|
|
240
|
+
|
|
241
|
+
return () => {
|
|
242
|
+
window.removeEventListener("storage", handleStorageChange);
|
|
243
|
+
};
|
|
244
|
+
}, [config]);
|
|
245
|
+
|
|
246
|
+
// Compute resolved theme
|
|
247
|
+
// During SSR (not mounted), use the initial theme or default to avoid hydration mismatch
|
|
248
|
+
const resolvedTheme: ResolvedTheme = useMemo(() => {
|
|
249
|
+
if (!mounted) {
|
|
250
|
+
// During SSR, return the initial theme if it's not "system", otherwise "light"
|
|
251
|
+
// The inline script will apply the correct class before hydration
|
|
252
|
+
if (initialTheme && initialTheme !== "system") {
|
|
253
|
+
return initialTheme as ResolvedTheme;
|
|
254
|
+
}
|
|
255
|
+
return "light";
|
|
256
|
+
}
|
|
257
|
+
if (theme === "system" && config.enableSystem) {
|
|
258
|
+
return systemTheme;
|
|
259
|
+
}
|
|
260
|
+
return theme as ResolvedTheme;
|
|
261
|
+
}, [theme, systemTheme, config.enableSystem, mounted, initialTheme]);
|
|
262
|
+
|
|
263
|
+
// Build themes list (include "system" if enabled)
|
|
264
|
+
const themes = useMemo(() => {
|
|
265
|
+
if (config.enableSystem) {
|
|
266
|
+
return ["system", ...config.themes.filter((t) => t !== "system")];
|
|
267
|
+
}
|
|
268
|
+
return config.themes;
|
|
269
|
+
}, [config.themes, config.enableSystem]);
|
|
270
|
+
|
|
271
|
+
// Context value
|
|
272
|
+
// During SSR (not mounted), return stable values to avoid hydration mismatch
|
|
273
|
+
const contextValue: ThemeContextValue = useMemo(
|
|
274
|
+
() => ({
|
|
275
|
+
theme,
|
|
276
|
+
setTheme,
|
|
277
|
+
resolvedTheme,
|
|
278
|
+
// Return stable "light" for systemTheme during SSR - actual value updates after mount
|
|
279
|
+
systemTheme: mounted ? systemTheme : "light",
|
|
280
|
+
themes,
|
|
281
|
+
config,
|
|
282
|
+
}),
|
|
283
|
+
[theme, setTheme, resolvedTheme, systemTheme, themes, config, mounted]
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<ThemeContext.Provider value={contextValue}>
|
|
288
|
+
{children}
|
|
289
|
+
</ThemeContext.Provider>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThemeScript - Server component that renders an inline script for FOUC prevention.
|
|
3
|
+
*
|
|
4
|
+
* This component renders a blocking inline script that:
|
|
5
|
+
* 1. Reads theme from cookie/localStorage before paint
|
|
6
|
+
* 2. Applies the theme to the HTML element immediately
|
|
7
|
+
* 3. Prevents flash of unstyled content (FOUC)
|
|
8
|
+
*
|
|
9
|
+
* Must be placed in the <head> element of your document, before any stylesheets.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* // In your document component
|
|
14
|
+
* import { ThemeScript } from "@rangojs/router/theme";
|
|
15
|
+
*
|
|
16
|
+
* export function Document({ children }) {
|
|
17
|
+
* return (
|
|
18
|
+
* <html lang="en" suppressHydrationWarning>
|
|
19
|
+
* <head>
|
|
20
|
+
* <ThemeScript />
|
|
21
|
+
* <MetaTags />
|
|
22
|
+
* </head>
|
|
23
|
+
* <body>{children}</body>
|
|
24
|
+
* </html>
|
|
25
|
+
* );
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import React from "react";
|
|
31
|
+
import { generateThemeScript } from "./theme-script.js";
|
|
32
|
+
import type { ResolvedThemeConfig } from "./types.js";
|
|
33
|
+
|
|
34
|
+
export interface ThemeScriptProps {
|
|
35
|
+
/**
|
|
36
|
+
* Theme configuration - passed from router.themeConfig
|
|
37
|
+
*/
|
|
38
|
+
config: ResolvedThemeConfig;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Optional nonce for CSP
|
|
42
|
+
*/
|
|
43
|
+
nonce?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Server component that renders the theme initialization script.
|
|
48
|
+
*
|
|
49
|
+
* This renders a synchronous inline script that applies the theme
|
|
50
|
+
* to the HTML element before React hydration, preventing FOUC.
|
|
51
|
+
*/
|
|
52
|
+
export function ThemeScript({ config, nonce }: ThemeScriptProps): React.ReactNode {
|
|
53
|
+
const scriptContent = generateThemeScript(config);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<script
|
|
57
|
+
nonce={nonce}
|
|
58
|
+
dangerouslySetInnerHTML={{ __html: scriptContent }}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resolveThemeConfig, THEME_DEFAULTS, THEME_COOKIE } from "../constants.js";
|
|
3
|
+
import { generateThemeScript } from "../theme-script.js";
|
|
4
|
+
import type { ThemeConfig, ResolvedThemeConfig } from "../types.js";
|
|
5
|
+
|
|
6
|
+
describe("Theme Configuration", () => {
|
|
7
|
+
describe("resolveThemeConfig", () => {
|
|
8
|
+
it("should apply defaults when no config provided", () => {
|
|
9
|
+
const config: ThemeConfig = {};
|
|
10
|
+
const resolved = resolveThemeConfig(config);
|
|
11
|
+
|
|
12
|
+
expect(resolved.defaultTheme).toBe(THEME_DEFAULTS.defaultTheme);
|
|
13
|
+
expect(resolved.themes).toEqual(THEME_DEFAULTS.themes);
|
|
14
|
+
expect(resolved.attribute).toBe(THEME_DEFAULTS.attribute);
|
|
15
|
+
expect(resolved.storageKey).toBe(THEME_DEFAULTS.storageKey);
|
|
16
|
+
expect(resolved.enableSystem).toBe(THEME_DEFAULTS.enableSystem);
|
|
17
|
+
expect(resolved.enableColorScheme).toBe(THEME_DEFAULTS.enableColorScheme);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should preserve custom config values", () => {
|
|
21
|
+
const config: ThemeConfig = {
|
|
22
|
+
defaultTheme: "dark",
|
|
23
|
+
themes: ["light", "dark", "sepia"],
|
|
24
|
+
attribute: "data-theme",
|
|
25
|
+
storageKey: "app-theme",
|
|
26
|
+
enableSystem: false,
|
|
27
|
+
enableColorScheme: false,
|
|
28
|
+
};
|
|
29
|
+
const resolved = resolveThemeConfig(config);
|
|
30
|
+
|
|
31
|
+
expect(resolved.defaultTheme).toBe("dark");
|
|
32
|
+
expect(resolved.themes).toEqual(["light", "dark", "sepia"]);
|
|
33
|
+
expect(resolved.attribute).toBe("data-theme");
|
|
34
|
+
expect(resolved.storageKey).toBe("app-theme");
|
|
35
|
+
expect(resolved.enableSystem).toBe(false);
|
|
36
|
+
expect(resolved.enableColorScheme).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should generate value mapping for themes", () => {
|
|
40
|
+
const config: ThemeConfig = {
|
|
41
|
+
themes: ["light", "dark"],
|
|
42
|
+
};
|
|
43
|
+
const resolved = resolveThemeConfig(config);
|
|
44
|
+
|
|
45
|
+
expect(resolved.value).toEqual({
|
|
46
|
+
light: "light",
|
|
47
|
+
dark: "dark",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should use custom value mapping when provided", () => {
|
|
52
|
+
const config: ThemeConfig = {
|
|
53
|
+
themes: ["light", "dark"],
|
|
54
|
+
value: {
|
|
55
|
+
light: "light-mode",
|
|
56
|
+
dark: "dark-mode",
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const resolved = resolveThemeConfig(config);
|
|
60
|
+
|
|
61
|
+
expect(resolved.value).toEqual({
|
|
62
|
+
light: "light-mode",
|
|
63
|
+
dark: "dark-mode",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("THEME_COOKIE", () => {
|
|
69
|
+
it("should have correct defaults", () => {
|
|
70
|
+
expect(THEME_COOKIE.maxAge).toBe(60 * 60 * 24 * 365);
|
|
71
|
+
expect(THEME_COOKIE.path).toBe("/");
|
|
72
|
+
expect(THEME_COOKIE.sameSite).toBe("lax");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("Theme Script", () => {
|
|
78
|
+
describe("generateThemeScript", () => {
|
|
79
|
+
it("should generate a minified script", () => {
|
|
80
|
+
const config: ResolvedThemeConfig = {
|
|
81
|
+
defaultTheme: "system",
|
|
82
|
+
themes: ["light", "dark"],
|
|
83
|
+
attribute: "class",
|
|
84
|
+
storageKey: "theme",
|
|
85
|
+
enableSystem: true,
|
|
86
|
+
enableColorScheme: true,
|
|
87
|
+
value: { light: "light", dark: "dark" },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const script = generateThemeScript(config);
|
|
91
|
+
|
|
92
|
+
// Should be a string
|
|
93
|
+
expect(typeof script).toBe("string");
|
|
94
|
+
|
|
95
|
+
// Should not have multi-line formatting (minified)
|
|
96
|
+
expect(script.includes("\n ")).toBe(false);
|
|
97
|
+
|
|
98
|
+
// Should contain key configuration values
|
|
99
|
+
expect(script.includes('"theme"')).toBe(true);
|
|
100
|
+
expect(script.includes('"system"')).toBe(true);
|
|
101
|
+
expect(script.includes('"class"')).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should handle data-* attributes", () => {
|
|
105
|
+
const config: ResolvedThemeConfig = {
|
|
106
|
+
defaultTheme: "light",
|
|
107
|
+
themes: ["light", "dark"],
|
|
108
|
+
attribute: "data-theme",
|
|
109
|
+
storageKey: "theme",
|
|
110
|
+
enableSystem: true,
|
|
111
|
+
enableColorScheme: true,
|
|
112
|
+
value: { light: "light", dark: "dark" },
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const script = generateThemeScript(config);
|
|
116
|
+
|
|
117
|
+
expect(script.includes('"data-theme"')).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default values for theme configuration
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ResolvedThemeConfig, ThemeConfig } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default theme configuration values
|
|
9
|
+
*/
|
|
10
|
+
export const THEME_DEFAULTS = {
|
|
11
|
+
defaultTheme: "system",
|
|
12
|
+
themes: ["light", "dark"],
|
|
13
|
+
attribute: "class",
|
|
14
|
+
storageKey: "theme",
|
|
15
|
+
enableSystem: true,
|
|
16
|
+
enableColorScheme: true,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Cookie configuration for theme persistence
|
|
21
|
+
*/
|
|
22
|
+
export const THEME_COOKIE = {
|
|
23
|
+
maxAge: 60 * 60 * 24 * 365, // 1 year
|
|
24
|
+
path: "/",
|
|
25
|
+
sameSite: "lax",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve theme config by applying defaults.
|
|
30
|
+
* Accepts `true` to enable with all defaults, or a config object.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveThemeConfig(config: ThemeConfig | true): ResolvedThemeConfig {
|
|
33
|
+
// Handle `theme: true` shorthand
|
|
34
|
+
if (config === true) {
|
|
35
|
+
config = {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const themes = config.themes ?? [...THEME_DEFAULTS.themes];
|
|
39
|
+
|
|
40
|
+
// Build value mapping - default to identity mapping
|
|
41
|
+
const value: Record<string, string> = {};
|
|
42
|
+
for (const theme of themes) {
|
|
43
|
+
value[theme] = config.value?.[theme] ?? theme;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
defaultTheme: config.defaultTheme ?? THEME_DEFAULTS.defaultTheme,
|
|
48
|
+
themes,
|
|
49
|
+
attribute: config.attribute ?? THEME_DEFAULTS.attribute,
|
|
50
|
+
storageKey: config.storageKey ?? THEME_DEFAULTS.storageKey,
|
|
51
|
+
enableSystem: config.enableSystem ?? THEME_DEFAULTS.enableSystem,
|
|
52
|
+
enableColorScheme: config.enableColorScheme ?? THEME_DEFAULTS.enableColorScheme,
|
|
53
|
+
value,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme module exports for @rangojs/router/theme
|
|
3
|
+
*
|
|
4
|
+
* This module provides theme management for rsc-router:
|
|
5
|
+
* - useTheme: Hook for accessing theme state in client components
|
|
6
|
+
* - ThemeProvider: Component for manual theme provider setup (typically not needed)
|
|
7
|
+
* - Types for theme configuration
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* // In a client component
|
|
12
|
+
* import { useTheme } from "@rangojs/router/theme";
|
|
13
|
+
*
|
|
14
|
+
* function ThemeToggle() {
|
|
15
|
+
* const { theme, setTheme, themes } = useTheme();
|
|
16
|
+
* return (
|
|
17
|
+
* <select value={theme} onChange={e => setTheme(e.target.value)}>
|
|
18
|
+
* {themes.map(t => <option key={t}>{t}</option>)}
|
|
19
|
+
* </select>
|
|
20
|
+
* );
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// Main hook for accessing theme
|
|
26
|
+
export { useTheme } from "./use-theme.js";
|
|
27
|
+
|
|
28
|
+
// Provider (typically auto-included via NavigationProvider when theme is enabled)
|
|
29
|
+
export { ThemeProvider } from "./ThemeProvider.js";
|
|
30
|
+
|
|
31
|
+
// Script component for FOUC prevention (use in document head)
|
|
32
|
+
export { ThemeScript, type ThemeScriptProps } from "./ThemeScript.js";
|
|
33
|
+
|
|
34
|
+
// Types
|
|
35
|
+
export type {
|
|
36
|
+
Theme,
|
|
37
|
+
ResolvedTheme,
|
|
38
|
+
ThemeAttribute,
|
|
39
|
+
ThemeConfig,
|
|
40
|
+
ResolvedThemeConfig,
|
|
41
|
+
UseThemeReturn,
|
|
42
|
+
ThemeProviderProps,
|
|
43
|
+
ThemeContextValue,
|
|
44
|
+
} from "./types.js";
|
|
45
|
+
|
|
46
|
+
// Constants (for advanced use cases)
|
|
47
|
+
export { THEME_DEFAULTS, THEME_COOKIE, resolveThemeConfig } from "./constants.js";
|
|
48
|
+
|
|
49
|
+
// Script generation (for advanced SSR use cases)
|
|
50
|
+
export { generateThemeScript, getNonceAttribute } from "./theme-script.js";
|
|
51
|
+
|
|
52
|
+
// Context (for advanced use cases)
|
|
53
|
+
export {
|
|
54
|
+
ThemeContext,
|
|
55
|
+
useThemeContext,
|
|
56
|
+
initThemeConfigSync,
|
|
57
|
+
getSSRThemeConfig,
|
|
58
|
+
} from "./theme-context.js";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Theme context for sharing theme state and configuration.
|
|
5
|
+
*
|
|
6
|
+
* Used by:
|
|
7
|
+
* - ThemeProvider to provide theme state
|
|
8
|
+
* - useTheme hook to access theme state
|
|
9
|
+
* - MetaTags to check if theme is enabled and render script
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createContext, useContext } from "react";
|
|
13
|
+
import type { ResolvedThemeConfig, ThemeContextValue } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* React context for theme state
|
|
17
|
+
* null when theme is not enabled in router config
|
|
18
|
+
*/
|
|
19
|
+
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* SSR module-level state for theme config.
|
|
23
|
+
* Populated by initThemeConfigSync before React renders.
|
|
24
|
+
* Used by MetaTags during SSR to render the theme script.
|
|
25
|
+
*/
|
|
26
|
+
let ssrThemeConfig: ResolvedThemeConfig | null = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Initialize theme config synchronously for SSR.
|
|
30
|
+
* Called before rendering to populate state for MetaTags.
|
|
31
|
+
*
|
|
32
|
+
* @param config - Theme config from router, or null if theme is disabled
|
|
33
|
+
*/
|
|
34
|
+
export function initThemeConfigSync(config: ResolvedThemeConfig | null): void {
|
|
35
|
+
ssrThemeConfig = config;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get theme config for SSR/hydration.
|
|
40
|
+
* Used by MetaTags to render the theme script.
|
|
41
|
+
*
|
|
42
|
+
* @returns Theme config if available, null otherwise
|
|
43
|
+
*/
|
|
44
|
+
export function getSSRThemeConfig(): ResolvedThemeConfig | null {
|
|
45
|
+
return ssrThemeConfig;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get theme context (internal use)
|
|
50
|
+
* Returns null if theme is not enabled
|
|
51
|
+
*/
|
|
52
|
+
export function useThemeContext(): ThemeContextValue | null {
|
|
53
|
+
return useContext(ThemeContext);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get theme context, throwing if not available
|
|
58
|
+
* Use this in useTheme hook
|
|
59
|
+
*/
|
|
60
|
+
export function requireThemeContext(): ThemeContextValue {
|
|
61
|
+
const ctx = useContext(ThemeContext);
|
|
62
|
+
if (!ctx) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
"useTheme must be used within a ThemeProvider. " +
|
|
65
|
+
"Make sure theme is enabled in your router config: " +
|
|
66
|
+
"createRSCRouter({ theme: { ... } })"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return ctx;
|
|
70
|
+
}
|