@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.
- package/dist/chunk-QVZFIUSH.js +13777 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +75 -0
- package/dist/next-config.d.ts +20 -0
- package/dist/next-config.js +305 -0
- package/dist/types-Cf4pRODK.d.ts +60 -0
- package/package.json +49 -0
- package/runtime/middleware.ts +52 -0
- package/runtime/src/app/[locale]/docs-internal/[[...slug]]/page.tsx +91 -0
- package/runtime/src/app/[locale]/docs-internal/ai/all/route.ts +52 -0
- package/runtime/src/app/[locale]/docs-internal/ai/index/route.ts +52 -0
- package/runtime/src/app/[locale]/docs-internal/markdown/[...slug]/route.ts +50 -0
- package/runtime/src/app/globals.css +1082 -0
- package/runtime/src/app/layout.tsx +37 -0
- package/runtime/src/app/page.tsx +26 -0
- package/runtime/src/components/code-group-enhancer.tsx +128 -0
- package/runtime/src/components/docs-shell.tsx +140 -0
- package/runtime/src/components/header-dropdown.tsx +138 -0
- package/runtime/src/components/icons.tsx +58 -0
- package/runtime/src/components/locale-switcher.tsx +97 -0
- package/runtime/src/components/mobile-docs-menu.tsx +208 -0
- package/runtime/src/components/site-header.tsx +44 -0
- package/runtime/src/components/site-search.tsx +358 -0
- package/runtime/src/components/theme-toggle.tsx +74 -0
- package/runtime/src/docs.config.ts +91 -0
- package/runtime/src/framework/config.ts +76 -0
- package/runtime/src/framework/errors.ts +34 -0
- package/runtime/src/framework/locales.ts +45 -0
- package/runtime/src/framework/markdown-search-text.ts +11 -0
- package/runtime/src/framework/navigation.ts +58 -0
- package/runtime/src/framework/next-config.ts +160 -0
- package/runtime/src/framework/rendering.ts +445 -0
- package/runtime/src/framework/repository.ts +255 -0
- package/runtime/src/framework/runtime-locales.ts +85 -0
- package/runtime/src/framework/search-index-types.ts +34 -0
- package/runtime/src/framework/search-index.ts +271 -0
- package/runtime/src/framework/service.ts +302 -0
- package/runtime/src/framework/settings.ts +43 -0
- package/runtime/src/framework/site-title.ts +54 -0
- package/runtime/src/framework/theme.ts +17 -0
- package/runtime/src/framework/types.ts +66 -0
- package/runtime/src/framework/url.ts +78 -0
- package/runtime/src/supatent/client.ts +2 -0
- package/src/index.ts +11 -0
- package/src/next-config.ts +5 -0
- 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
|
+
}
|