@supatent/supatent-docs 0.1.0

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.
Files changed (46) hide show
  1. package/dist/chunk-QVZFIUSH.js +13777 -0
  2. package/dist/index.d.ts +6 -0
  3. package/dist/index.js +75 -0
  4. package/dist/next-config.d.ts +20 -0
  5. package/dist/next-config.js +305 -0
  6. package/dist/types-Cf4pRODK.d.ts +60 -0
  7. package/package.json +49 -0
  8. package/runtime/middleware.ts +52 -0
  9. package/runtime/src/app/[locale]/docs-internal/[[...slug]]/page.tsx +91 -0
  10. package/runtime/src/app/[locale]/docs-internal/ai/all/route.ts +52 -0
  11. package/runtime/src/app/[locale]/docs-internal/ai/index/route.ts +52 -0
  12. package/runtime/src/app/[locale]/docs-internal/markdown/[...slug]/route.ts +50 -0
  13. package/runtime/src/app/globals.css +1082 -0
  14. package/runtime/src/app/layout.tsx +37 -0
  15. package/runtime/src/app/page.tsx +26 -0
  16. package/runtime/src/components/code-group-enhancer.tsx +128 -0
  17. package/runtime/src/components/docs-shell.tsx +140 -0
  18. package/runtime/src/components/header-dropdown.tsx +138 -0
  19. package/runtime/src/components/icons.tsx +58 -0
  20. package/runtime/src/components/locale-switcher.tsx +97 -0
  21. package/runtime/src/components/mobile-docs-menu.tsx +208 -0
  22. package/runtime/src/components/site-header.tsx +44 -0
  23. package/runtime/src/components/site-search.tsx +358 -0
  24. package/runtime/src/components/theme-toggle.tsx +74 -0
  25. package/runtime/src/docs.config.ts +91 -0
  26. package/runtime/src/framework/config.ts +76 -0
  27. package/runtime/src/framework/errors.ts +34 -0
  28. package/runtime/src/framework/locales.ts +45 -0
  29. package/runtime/src/framework/markdown-search-text.ts +11 -0
  30. package/runtime/src/framework/navigation.ts +58 -0
  31. package/runtime/src/framework/next-config.ts +160 -0
  32. package/runtime/src/framework/rendering.ts +445 -0
  33. package/runtime/src/framework/repository.ts +255 -0
  34. package/runtime/src/framework/runtime-locales.ts +85 -0
  35. package/runtime/src/framework/search-index-types.ts +34 -0
  36. package/runtime/src/framework/search-index.ts +271 -0
  37. package/runtime/src/framework/service.ts +302 -0
  38. package/runtime/src/framework/settings.ts +43 -0
  39. package/runtime/src/framework/site-title.ts +54 -0
  40. package/runtime/src/framework/theme.ts +17 -0
  41. package/runtime/src/framework/types.ts +66 -0
  42. package/runtime/src/framework/url.ts +78 -0
  43. package/runtime/src/supatent/client.ts +2 -0
  44. package/src/index.ts +11 -0
  45. package/src/next-config.ts +5 -0
  46. package/src/next.ts +10 -0
@@ -0,0 +1,208 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { usePathname } from "next/navigation";
5
+ import type { DocsNavNode } from "../framework/types";
6
+ import { CloseIcon, MenuIcon } from "./icons";
7
+ import { ThemeToggle } from "./theme-toggle";
8
+ import { LocaleSwitcher } from "./locale-switcher";
9
+
10
+ type TocItem = {
11
+ id: string;
12
+ label: string;
13
+ depth: number;
14
+ };
15
+
16
+ type MobileDocsMenuProps = {
17
+ navigation: DocsNavNode[];
18
+ activeSlug: string;
19
+ tocItems: TocItem[];
20
+ locales: string[];
21
+ defaultLocale: string;
22
+ basePath: string;
23
+ };
24
+
25
+ function MobileNavTree({
26
+ nodes,
27
+ activeSlug,
28
+ onNavigate,
29
+ }: {
30
+ nodes: DocsNavNode[];
31
+ activeSlug: string;
32
+ onNavigate: () => void;
33
+ }) {
34
+ return (
35
+ <ul className="docs-mobile-nav-tree">
36
+ {nodes.map((node) => {
37
+ const isCategory = node.path === null && node.children.length > 0;
38
+
39
+ if (isCategory) {
40
+ return (
41
+ <li key={node.id} className="docs-mobile-category">
42
+ <details open className="docs-mobile-category-details">
43
+ <summary className="docs-mobile-category-summary">
44
+ <span>{node.title}</span>
45
+ <span className="docs-mobile-category-chevron" aria-hidden="true">
46
+
47
+ </span>
48
+ </summary>
49
+ <ul className="docs-mobile-nav-tree">
50
+ {node.children.map((child) => {
51
+ const isChildActive = child.slug === activeSlug;
52
+ return (
53
+ <li key={child.id}>
54
+ {child.path ? (
55
+ <a
56
+ href={child.path}
57
+ className={
58
+ isChildActive ? "docs-nav-link docs-nav-link-active" : "docs-nav-link"
59
+ }
60
+ aria-current={isChildActive ? "page" : undefined}
61
+ onClick={onNavigate}
62
+ >
63
+ {child.title}
64
+ </a>
65
+ ) : null}
66
+ </li>
67
+ );
68
+ })}
69
+ </ul>
70
+ </details>
71
+ </li>
72
+ );
73
+ }
74
+
75
+ const isActive = node.slug === activeSlug;
76
+ return (
77
+ <li key={node.id}>
78
+ {node.path ? (
79
+ <a
80
+ href={node.path}
81
+ className={isActive ? "docs-nav-link docs-nav-link-active" : "docs-nav-link"}
82
+ aria-current={isActive ? "page" : undefined}
83
+ onClick={onNavigate}
84
+ >
85
+ {node.title}
86
+ </a>
87
+ ) : (
88
+ <span className="docs-nav-group-label">{node.title}</span>
89
+ )}
90
+ </li>
91
+ );
92
+ })}
93
+ </ul>
94
+ );
95
+ }
96
+
97
+ export function MobileDocsMenu({
98
+ navigation,
99
+ activeSlug,
100
+ tocItems,
101
+ locales,
102
+ defaultLocale,
103
+ basePath,
104
+ }: MobileDocsMenuProps) {
105
+ const pathname = usePathname();
106
+ const [open, setOpen] = useState(false);
107
+
108
+ useEffect(() => {
109
+ setOpen(false);
110
+ }, [pathname]);
111
+
112
+ useEffect(() => {
113
+ if (!open) return;
114
+
115
+ const originalOverflow = document.body.style.overflow;
116
+ const onKeyDown = (event: KeyboardEvent) => {
117
+ if (event.key === "Escape") {
118
+ setOpen(false);
119
+ }
120
+ };
121
+
122
+ document.body.style.overflow = "hidden";
123
+ document.addEventListener("keydown", onKeyDown);
124
+
125
+ return () => {
126
+ document.body.style.overflow = originalOverflow;
127
+ document.removeEventListener("keydown", onKeyDown);
128
+ };
129
+ }, [open]);
130
+
131
+ return (
132
+ <div className="docs-mobile-menu">
133
+ <button
134
+ type="button"
135
+ className="docs-mobile-menu-trigger"
136
+ aria-label="Open navigation menu"
137
+ aria-expanded={open}
138
+ aria-controls="docs-mobile-drawer"
139
+ onClick={() => setOpen(true)}
140
+ >
141
+ <MenuIcon />
142
+ <span>Menu</span>
143
+ </button>
144
+
145
+ {open ? (
146
+ <button
147
+ type="button"
148
+ className="docs-mobile-menu-overlay"
149
+ aria-label="Close menu"
150
+ onClick={() => setOpen(false)}
151
+ />
152
+ ) : null}
153
+
154
+ {open ? (
155
+ <aside
156
+ id="docs-mobile-drawer"
157
+ className="docs-mobile-menu-panel docs-mobile-menu-panel-open"
158
+ >
159
+ <div className="docs-mobile-menu-header">
160
+ <h2>Docs menu</h2>
161
+ <button
162
+ type="button"
163
+ className="icon-link docs-mobile-close"
164
+ aria-label="Close navigation menu"
165
+ onClick={() => setOpen(false)}
166
+ >
167
+ <CloseIcon />
168
+ </button>
169
+ </div>
170
+
171
+ <section className="docs-mobile-menu-section">
172
+ <h3>Navigation</h3>
173
+ <MobileNavTree
174
+ nodes={navigation}
175
+ activeSlug={activeSlug}
176
+ onNavigate={() => setOpen(false)}
177
+ />
178
+ </section>
179
+
180
+ <section className="docs-mobile-menu-section">
181
+ <ul className="docs-mobile-toc">
182
+ {tocItems.map((item) => (
183
+ <li key={item.id} className={item.depth > 2 ? "toc-nested" : undefined}>
184
+ <a href={`#${item.id}`} onClick={() => setOpen(false)}>
185
+ {item.label}
186
+ </a>
187
+ </li>
188
+ ))}
189
+ </ul>
190
+ </section>
191
+
192
+ <section className="docs-mobile-menu-section">
193
+ <h3>Preferences</h3>
194
+ <div className="docs-mobile-preferences">
195
+ <ThemeToggle showLabel className="docs-mobile-theme-toggle" />
196
+ <LocaleSwitcher
197
+ locales={locales}
198
+ defaultLocale={defaultLocale}
199
+ basePath={basePath}
200
+ className="docs-mobile-locale-switcher"
201
+ />
202
+ </div>
203
+ </section>
204
+ </aside>
205
+ ) : null}
206
+ </div>
207
+ );
208
+ }
@@ -0,0 +1,44 @@
1
+ import { docsConfig } from "../docs.config";
2
+ import { headers } from "next/headers";
3
+ import { toDocsPagePath } from "../framework/url";
4
+ import { ThemeToggle } from "./theme-toggle";
5
+ import { LocaleSwitcher } from "./locale-switcher";
6
+ import { getRuntimeLocales } from "../framework/runtime-locales";
7
+ import { getSiteTitle } from "../framework/site-title";
8
+ import { SiteSearch } from "./site-search";
9
+
10
+ export async function SiteHeader() {
11
+ const requestHeaders = await headers();
12
+ const runtimeLocales = await getRuntimeLocales();
13
+ const requestedLocale = requestHeaders.get("x-docs-request-locale");
14
+ const siteTitle = await getSiteTitle(requestedLocale);
15
+ const homeDocsPath = toDocsPagePath(
16
+ docsConfig,
17
+ runtimeLocales.defaultLocale,
18
+ );
19
+
20
+ return (
21
+ <header className="topbar">
22
+ <div className="topbar-inner">
23
+ <a href={homeDocsPath} className="brand" aria-label="Home">
24
+ {siteTitle ? <span className="brand-text">{siteTitle}</span> : null}
25
+ </a>
26
+
27
+ <SiteSearch
28
+ locales={runtimeLocales.locales}
29
+ defaultLocale={runtimeLocales.defaultLocale}
30
+ basePath={docsConfig.routing.basePath}
31
+ />
32
+
33
+ <div className="topbar-actions">
34
+ <ThemeToggle />
35
+ <LocaleSwitcher
36
+ locales={runtimeLocales.locales}
37
+ defaultLocale={runtimeLocales.defaultLocale}
38
+ basePath={docsConfig.routing.basePath}
39
+ />
40
+ </div>
41
+ </div>
42
+ </header>
43
+ );
44
+ }
@@ -0,0 +1,358 @@
1
+ "use client";
2
+
3
+ import { usePathname } from "next/navigation";
4
+ import { useEffect, useMemo, useRef, useState } from "react";
5
+ import {
6
+ SEARCH_INDEX_PUBLIC_BASE_PATH,
7
+ type SearchIndexDocument,
8
+ type SearchIndexManifest,
9
+ } from "../framework/search-index-types";
10
+
11
+ type SiteSearchProps = {
12
+ locales: string[];
13
+ defaultLocale: string;
14
+ basePath: string;
15
+ };
16
+
17
+ type SearchResult = {
18
+ document: SearchIndexDocument;
19
+ score: number;
20
+ };
21
+
22
+ type AiAllResponse = {
23
+ requestedLocale?: string;
24
+ resolvedLocale?: string;
25
+ pages?: Array<{
26
+ slug: string;
27
+ title: string;
28
+ locale: string;
29
+ plainText?: string;
30
+ markdown?: string;
31
+ htmlPath?: string;
32
+ }>;
33
+ };
34
+
35
+ function normalizeForSearch(value: string): string {
36
+ return value.toLowerCase().replace(/[^\p{L}\p{N}\s/-]/gu, " ").replace(/\s+/g, " ").trim();
37
+ }
38
+
39
+ function toTokens(value: string): string[] {
40
+ return normalizeForSearch(value).split(" ").filter((token) => token.length > 1);
41
+ }
42
+
43
+ function trimSlashes(value: string): string {
44
+ return value.replace(/^\/+|\/+$/g, "");
45
+ }
46
+
47
+ function joinPath(...segments: Array<string | undefined>): string {
48
+ const cleaned = segments
49
+ .filter((segment): segment is string => Boolean(segment))
50
+ .map(trimSlashes)
51
+ .filter(Boolean);
52
+
53
+ return `/${cleaned.join("/")}`;
54
+ }
55
+
56
+ function resolveActiveLocale(
57
+ pathname: string,
58
+ locales: string[],
59
+ defaultLocale: string,
60
+ ): string {
61
+ const firstSegment = pathname.split("/").filter(Boolean)[0];
62
+ if (firstSegment && locales.includes(firstSegment)) {
63
+ return firstSegment;
64
+ }
65
+
66
+ return defaultLocale;
67
+ }
68
+
69
+ function scoreDocument(document: SearchIndexDocument, normalizedQuery: string, tokens: string[]): number {
70
+ const title = normalizeForSearch(document.title);
71
+ const slug = normalizeForSearch(document.slug);
72
+ const body = normalizeForSearch(document.plainText);
73
+
74
+ let score = 0;
75
+
76
+ if (title === normalizedQuery) score += 120;
77
+ if (title.includes(normalizedQuery)) score += 60;
78
+ if (slug.includes(normalizedQuery)) score += 30;
79
+ if (body.includes(normalizedQuery)) score += 20;
80
+
81
+ for (const token of tokens) {
82
+ if (title.includes(token)) {
83
+ score += 12;
84
+ } else if (slug.includes(token)) {
85
+ score += 8;
86
+ } else if (body.includes(token)) {
87
+ score += 3;
88
+ }
89
+ }
90
+
91
+ return score;
92
+ }
93
+
94
+ function buildResults(documents: SearchIndexDocument[], query: string): SearchResult[] {
95
+ const normalizedQuery = normalizeForSearch(query);
96
+ if (!normalizedQuery) {
97
+ return [];
98
+ }
99
+
100
+ const tokens = toTokens(query);
101
+
102
+ return documents
103
+ .map((document) => ({
104
+ document,
105
+ score: scoreDocument(document, normalizedQuery, tokens),
106
+ }))
107
+ .filter((entry) => entry.score > 0)
108
+ .sort((a, b) => {
109
+ if (a.score !== b.score) {
110
+ return b.score - a.score;
111
+ }
112
+ if (a.document.order !== b.document.order) {
113
+ return a.document.order - b.document.order;
114
+ }
115
+ return a.document.title.localeCompare(b.document.title);
116
+ })
117
+ .slice(0, 8);
118
+ }
119
+
120
+ export function SiteSearch({ locales, defaultLocale, basePath }: SiteSearchProps) {
121
+ const pathname = usePathname();
122
+ const activeLocale = useMemo(
123
+ () => resolveActiveLocale(pathname ?? "/", locales, defaultLocale),
124
+ [defaultLocale, locales, pathname],
125
+ );
126
+ const rootRef = useRef<HTMLDivElement | null>(null);
127
+ const inputRef = useRef<HTMLInputElement | null>(null);
128
+ const manifestRef = useRef<Promise<SearchIndexManifest> | null>(null);
129
+ const documentsCacheRef = useRef<Map<string, SearchIndexDocument[]>>(new Map());
130
+
131
+ const [query, setQuery] = useState("");
132
+ const [open, setOpen] = useState(false);
133
+ const [loading, setLoading] = useState(false);
134
+ const [error, setError] = useState<string | null>(null);
135
+ const [results, setResults] = useState<SearchResult[]>([]);
136
+ const [activeIndex, setActiveIndex] = useState<number>(-1);
137
+
138
+ useEffect(() => {
139
+ const onPointerDown = (event: MouseEvent) => {
140
+ if (!rootRef.current?.contains(event.target as Node)) {
141
+ setOpen(false);
142
+ }
143
+ };
144
+
145
+ const onKeyDown = (event: KeyboardEvent) => {
146
+ const key = event.key.toLowerCase();
147
+ if ((event.ctrlKey || event.metaKey) && key === "k") {
148
+ event.preventDefault();
149
+ inputRef.current?.focus();
150
+ setOpen(true);
151
+ return;
152
+ }
153
+
154
+ if (key === "escape") {
155
+ setOpen(false);
156
+ }
157
+ };
158
+
159
+ document.addEventListener("mousedown", onPointerDown);
160
+ document.addEventListener("keydown", onKeyDown);
161
+
162
+ return () => {
163
+ document.removeEventListener("mousedown", onPointerDown);
164
+ document.removeEventListener("keydown", onKeyDown);
165
+ };
166
+ }, []);
167
+
168
+ async function getManifest(): Promise<SearchIndexManifest> {
169
+ if (!manifestRef.current) {
170
+ manifestRef.current = fetch(`${SEARCH_INDEX_PUBLIC_BASE_PATH}/manifest.json`, {
171
+ cache: "force-cache",
172
+ }).then(async (response) => {
173
+ if (!response.ok) {
174
+ throw new Error(`Missing search manifest (${response.status})`);
175
+ }
176
+
177
+ return (await response.json()) as SearchIndexManifest;
178
+ });
179
+ }
180
+
181
+ return manifestRef.current;
182
+ }
183
+
184
+ async function loadFromAiAll(locale: string): Promise<SearchIndexDocument[]> {
185
+ const route = joinPath(locale, basePath, "ai", "all");
186
+ const response = await fetch(route, { cache: "no-store" });
187
+ if (!response.ok) {
188
+ throw new Error(`Search source unavailable (${response.status})`);
189
+ }
190
+
191
+ const payload = (await response.json()) as AiAllResponse;
192
+
193
+ return (payload.pages ?? []).map((page, index) => ({
194
+ id: `${locale}:${page.slug}`,
195
+ locale,
196
+ slug: page.slug,
197
+ title: page.title,
198
+ path: joinPath(locale, basePath, page.slug),
199
+ order: index,
200
+ plainText: page.plainText ?? page.markdown ?? "",
201
+ }));
202
+ }
203
+
204
+ async function loadLocaleDocuments(locale: string): Promise<SearchIndexDocument[]> {
205
+ const cached = documentsCacheRef.current.get(locale);
206
+ if (cached) {
207
+ return cached;
208
+ }
209
+
210
+ try {
211
+ const manifest = await getManifest();
212
+ const entry = manifest.entries.find((item) => item.locale === locale);
213
+
214
+ if (entry) {
215
+ const indexResponse = await fetch(`${SEARCH_INDEX_PUBLIC_BASE_PATH}/${entry.file}`, {
216
+ cache: "force-cache",
217
+ });
218
+ if (!indexResponse.ok) {
219
+ throw new Error(`Failed to load index for locale ${locale}`);
220
+ }
221
+
222
+ const payload = (await indexResponse.json()) as { documents?: SearchIndexDocument[] };
223
+ const documents = payload.documents ?? [];
224
+ if (documents.length > 0) {
225
+ documentsCacheRef.current.set(locale, documents);
226
+ return documents;
227
+ }
228
+ }
229
+ } catch {
230
+ // Fall back to runtime docs endpoint when static search assets are unavailable.
231
+ }
232
+
233
+ const documents = await loadFromAiAll(locale);
234
+ documentsCacheRef.current.set(locale, documents);
235
+ return documents;
236
+ }
237
+
238
+ useEffect(() => {
239
+ let cancelled = false;
240
+
241
+ const trimmed = query.trim();
242
+ if (trimmed.length < 2) {
243
+ setResults([]);
244
+ setError(null);
245
+ setLoading(false);
246
+ setActiveIndex(-1);
247
+ return;
248
+ }
249
+
250
+ setLoading(true);
251
+ setError(null);
252
+ const timeout = setTimeout(async () => {
253
+ try {
254
+ const documents = await loadLocaleDocuments(activeLocale);
255
+ if (cancelled) return;
256
+
257
+ const nextResults = buildResults(documents, trimmed);
258
+ setResults(nextResults);
259
+ setActiveIndex(nextResults.length > 0 ? 0 : -1);
260
+ } catch (searchError) {
261
+ if (cancelled) return;
262
+ const message =
263
+ searchError instanceof Error ? searchError.message : "Could not search right now";
264
+ setError(message);
265
+ setResults([]);
266
+ setActiveIndex(-1);
267
+ } finally {
268
+ if (!cancelled) {
269
+ setLoading(false);
270
+ setOpen(true);
271
+ }
272
+ }
273
+ }, 140);
274
+
275
+ return () => {
276
+ cancelled = true;
277
+ clearTimeout(timeout);
278
+ };
279
+ }, [activeLocale, query]);
280
+
281
+ return (
282
+ <div className="search-wrap" ref={rootRef}>
283
+ <label className="search-box" aria-label="Search docs">
284
+ <span className="search-icon" aria-hidden="true">
285
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
286
+ <path
287
+ fill="currentColor"
288
+ d="M9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l5.6 5.6q.275.275.275.7t-.275.7t-.7.275t-.7-.275l-5.6-5.6q-.75.6-1.725.95T9.5 16m0-2q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14"
289
+ />
290
+ </svg>
291
+ </span>
292
+ <input
293
+ ref={inputRef}
294
+ placeholder="..."
295
+ value={query}
296
+ onFocus={() => setOpen(true)}
297
+ onChange={(event) => {
298
+ setQuery(event.target.value);
299
+ setOpen(true);
300
+ }}
301
+ onKeyDown={(event) => {
302
+ if (event.key === "ArrowDown") {
303
+ event.preventDefault();
304
+ setOpen(true);
305
+ setActiveIndex((current) =>
306
+ results.length === 0 ? -1 : Math.min(current + 1, results.length - 1),
307
+ );
308
+ return;
309
+ }
310
+
311
+ if (event.key === "ArrowUp") {
312
+ event.preventDefault();
313
+ setActiveIndex((current) =>
314
+ results.length === 0 ? -1 : Math.max(current - 1, 0),
315
+ );
316
+ return;
317
+ }
318
+
319
+ if (event.key === "Enter" && activeIndex >= 0 && results[activeIndex]) {
320
+ event.preventDefault();
321
+ window.location.assign(results[activeIndex].document.path);
322
+ }
323
+ }}
324
+ />
325
+ <span className="kbd">Ctrl K</span>
326
+ </label>
327
+
328
+ {open && query.trim().length >= 2 ? (
329
+ <div className="search-results" role="listbox" aria-label="Search results">
330
+ {loading ? <div className="search-status">Searching…</div> : null}
331
+ {!loading && error ? <div className="search-status">{error}</div> : null}
332
+ {!loading && !error && results.length === 0 ? (
333
+ <div className="search-status">No results for this locale.</div>
334
+ ) : null}
335
+
336
+ {!loading && !error && results.length > 0 ? (
337
+ <ul className="search-results-list">
338
+ {results.map((result, index) => {
339
+ const isActive = index === activeIndex;
340
+ return (
341
+ <li key={result.document.id}>
342
+ <a
343
+ href={result.document.path}
344
+ className={isActive ? "search-result-link search-result-link-active" : "search-result-link"}
345
+ onMouseEnter={() => setActiveIndex(index)}
346
+ >
347
+ <span className="search-result-title">{result.document.title}</span>
348
+ </a>
349
+ </li>
350
+ );
351
+ })}
352
+ </ul>
353
+ ) : null}
354
+ </div>
355
+ ) : null}
356
+ </div>
357
+ );
358
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { MoonIcon, SunIcon } from "./icons";
5
+
6
+ type ThemePreference = "light" | "dark" | "system";
7
+ type Theme = "light" | "dark";
8
+
9
+ type ThemeToggleProps = {
10
+ className?: string;
11
+ showLabel?: boolean;
12
+ };
13
+
14
+ const STORAGE_KEY = "docs-theme";
15
+
16
+ function getSystemTheme(): Theme {
17
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
18
+ }
19
+
20
+ function resolveTheme(preference: ThemePreference): Theme {
21
+ if (preference === "system") {
22
+ return getSystemTheme();
23
+ }
24
+
25
+ return preference;
26
+ }
27
+
28
+ function isThemePreference(value: string | null): value is ThemePreference {
29
+ return value === "light" || value === "dark" || value === "system";
30
+ }
31
+
32
+ export function ThemeToggle({ className, showLabel = false }: ThemeToggleProps) {
33
+ const [theme, setTheme] = useState<Theme>("light");
34
+
35
+ useEffect(() => {
36
+ const stored = localStorage.getItem(STORAGE_KEY);
37
+ const initial = isThemePreference(stored) ? stored : "system";
38
+ const resolved = resolveTheme(initial);
39
+ setTheme(resolved);
40
+ document.documentElement.dataset.theme = resolved;
41
+
42
+ const query = window.matchMedia("(prefers-color-scheme: dark)");
43
+ const onChange = () => {
44
+ const current = localStorage.getItem(STORAGE_KEY);
45
+ if (current === null || current === "system") {
46
+ const next = getSystemTheme();
47
+ setTheme(next);
48
+ document.documentElement.dataset.theme = next;
49
+ }
50
+ };
51
+
52
+ query.addEventListener("change", onChange);
53
+ return () => query.removeEventListener("change", onChange);
54
+ }, []);
55
+
56
+ return (
57
+ <button
58
+ type="button"
59
+ className={className ? `icon-link theme-toggle-button ${className}` : "icon-link theme-toggle-button"}
60
+ aria-label="Toggle theme"
61
+ onClick={() => {
62
+ const nextTheme: Theme = theme === "dark" ? "light" : "dark";
63
+ setTheme(nextTheme);
64
+ localStorage.setItem(STORAGE_KEY, nextTheme);
65
+ document.documentElement.dataset.theme = nextTheme;
66
+ }}
67
+ >
68
+ {theme === "dark" ? <MoonIcon aria-hidden="true" /> : <SunIcon aria-hidden="true" />}
69
+ {showLabel ? (
70
+ <span className="theme-toggle-label">{theme === "dark" ? "Dark" : "Light"}</span>
71
+ ) : null}
72
+ </button>
73
+ );
74
+ }