doccupine 0.0.83 → 0.0.84

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 (36) hide show
  1. package/dist/lib/layout.js +7 -2
  2. package/dist/lib/structures.js +6 -0
  3. package/dist/templates/app/robots.d.ts +1 -0
  4. package/dist/templates/app/robots.js +11 -0
  5. package/dist/templates/app/theme.d.ts +1 -1
  6. package/dist/templates/app/theme.js +1 -1
  7. package/dist/templates/components/DocsSideBar.d.ts +1 -1
  8. package/dist/templates/components/DocsSideBar.js +2 -2
  9. package/dist/templates/components/PostHogProvider.d.ts +1 -1
  10. package/dist/templates/components/PostHogProvider.js +9 -62
  11. package/dist/templates/components/PostHogProviderLazy.d.ts +1 -0
  12. package/dist/templates/components/PostHogProviderLazy.js +70 -0
  13. package/dist/templates/components/SearchDocs.d.ts +1 -1
  14. package/dist/templates/components/SearchDocs.js +40 -270
  15. package/dist/templates/components/SearchModalContent.d.ts +1 -0
  16. package/dist/templates/components/SearchModalContent.js +326 -0
  17. package/dist/templates/components/SideBar.d.ts +1 -1
  18. package/dist/templates/components/SideBar.js +5 -1
  19. package/dist/templates/components/layout/DocsComponents.d.ts +1 -1
  20. package/dist/templates/components/layout/DocsComponents.js +1 -1
  21. package/dist/templates/components/layout/DocsNavigation.d.ts +1 -1
  22. package/dist/templates/components/layout/DocsNavigation.js +1 -1
  23. package/dist/templates/components/layout/Footer.d.ts +1 -1
  24. package/dist/templates/components/layout/Footer.js +8 -3
  25. package/dist/templates/components/layout/SharedStyles.d.ts +1 -1
  26. package/dist/templates/components/layout/SharedStyles.js +2 -1
  27. package/dist/templates/components/layout/StaticLinks.d.ts +1 -1
  28. package/dist/templates/components/layout/StaticLinks.js +1 -1
  29. package/dist/templates/mdx/theme.mdx.d.ts +1 -1
  30. package/dist/templates/mdx/theme.mdx.js +1 -1
  31. package/dist/templates/package.js +10 -10
  32. package/dist/templates/services/mcp/tools.d.ts +1 -1
  33. package/dist/templates/services/mcp/tools.js +9 -10
  34. package/dist/templates/tsconfig.d.ts +1 -1
  35. package/dist/templates/tsconfig.js +0 -1
  36. package/package.json +3 -3
@@ -8,20 +8,19 @@ import React, {
8
8
  useState,
9
9
  } from "react";
10
10
  import { useRouter } from "next/navigation";
11
- import styled, { css, keyframes } from "styled-components";
12
- import { rgba } from "polished";
13
- import { Search } from "lucide-react";
11
+ import dynamic from "next/dynamic";
12
+ import styled from "styled-components";
14
13
  import { mq, Theme } from "@/app/theme";
15
14
  import { interactiveStyles } from "@/components/layout/SharedStyled";
16
- import { Spinner } from "@/components/Spinner";
15
+ import type { PageItem, MergedResult } from "@/components/SearchModalContent";
17
16
 
18
- interface PageItem {
19
- slug: string;
20
- title: string;
21
- description?: string;
22
- category: string;
23
- section?: string;
24
- }
17
+ const SearchModalContent = dynamic(
18
+ () =>
19
+ import("@/components/SearchModalContent").then(
20
+ (mod) => mod.SearchModalContent,
21
+ ),
22
+ { ssr: false },
23
+ );
25
24
 
26
25
  interface SectionItem {
27
26
  label: string;
@@ -33,11 +32,6 @@ interface ContentHit {
33
32
  snippet: string;
34
33
  }
35
34
 
36
- interface MergedResult {
37
- page: PageItem;
38
- snippet?: string;
39
- }
40
-
41
35
  interface SearchContextValue {
42
36
  openSearch: () => void;
43
37
  }
@@ -46,170 +40,6 @@ const SearchContext = createContext<SearchContextValue>({
46
40
  openSearch: () => {},
47
41
  });
48
42
 
49
- const ANIMATION_MS = 150;
50
-
51
- const backdropIn = keyframes\`
52
- from { opacity: 0; }
53
- to { opacity: 1; }
54
- \`;
55
-
56
- const backdropOut = keyframes\`
57
- from { opacity: 1; }
58
- to { opacity: 0; }
59
- \`;
60
-
61
- const modalIn = keyframes\`
62
- from { opacity: 0; transform: scale(0.96) translateY(-8px); }
63
- to { opacity: 1; transform: scale(1) translateY(0); }
64
- \`;
65
-
66
- const modalOut = keyframes\`
67
- from { opacity: 1; transform: scale(1) translateY(0); }
68
- to { opacity: 0; transform: scale(0.96) translateY(-8px); }
69
- \`;
70
-
71
- const StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>\`
72
- position: fixed;
73
- inset: 0;
74
- z-index: 9999;
75
- background: \${({ theme }) =>
76
- rgba(theme.isDark ? theme.colors.light : theme.colors.dark, 0.5)};
77
- backdrop-filter: blur(4px);
78
- -webkit-backdrop-filter: blur(4px);
79
- display: flex;
80
- align-items: flex-start;
81
- justify-content: center;
82
- padding: 20px;
83
- animation: \${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}
84
- \${ANIMATION_MS}ms ease forwards;
85
-
86
- \${mq("lg")} {
87
- padding: 120px 20px 20px 20px;
88
- }
89
- \`;
90
-
91
- const StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>\`
92
- background: \${({ theme }) => theme.colors.light};
93
- border-radius: \${({ theme }) => theme.spacing.radius.lg};
94
- box-shadow: \${({ theme }) => theme.shadows.xs};
95
- width: 100%;
96
- max-width: 560px;
97
- max-height: calc(100dvh - 40px);
98
- display: flex;
99
- flex-direction: column;
100
- border: solid 1px \${({ theme }) => theme.colors.grayLight};
101
- padding-bottom: 8px;
102
- animation: \${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}
103
- \${ANIMATION_MS}ms ease forwards;
104
-
105
- \${mq("lg")} {
106
- max-height: calc(100dvh - 240px);
107
- }
108
- \`;
109
-
110
- const StyledInputWrapper = styled.div<{ theme: Theme }>\`
111
- display: flex;
112
- align-items: center;
113
- gap: 12px;
114
- padding: 16px;
115
- flex-shrink: 0;
116
- border-bottom: solid 1px \${({ theme }) => theme.colors.grayLight};
117
-
118
- & svg.lucide {
119
- color: \${({ theme }) => theme.colors.gray};
120
- flex-shrink: 0;
121
- }
122
- \`;
123
-
124
- const StyledInput = styled.input<{ theme: Theme }>\`
125
- flex: 1;
126
- border: none;
127
- outline: none;
128
- background: transparent;
129
- font-size: \${({ theme }) => theme.fontSizes.text.lg};
130
- line-height: \${({ theme }) => theme.lineHeights.text.lg};
131
- color: \${({ theme }) => theme.colors.dark};
132
- font-family: inherit;
133
-
134
- &::placeholder {
135
- color: \${({ theme }) => theme.colors.gray};
136
- }
137
- \`;
138
-
139
- const StyledResults = styled.ul<{ theme: Theme }>\`
140
- list-style: none;
141
- margin: 8px 0 0 0;
142
- padding: 0 8px;
143
- overflow-y: auto;
144
- flex: 1;
145
- min-height: 0;
146
- -webkit-overflow-scrolling: touch;
147
-
148
- &::-webkit-scrollbar {
149
- display: none;
150
- }
151
- \`;
152
-
153
- const StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>\`
154
- padding: 10px 12px;
155
- border-radius: \${({ theme }) => theme.spacing.radius.xs};
156
- cursor: pointer;
157
- transition: background 0.15s ease;
158
-
159
- \${({ $isActive, theme }) =>
160
- $isActive &&
161
- css\`
162
- background: \${rgba(theme.colors.primaryLight, 0.2)};
163
- \`}
164
-
165
- &:hover {
166
- background: \${({ theme }) => rgba(theme.colors.primaryLight, 0.15)};
167
- }
168
- \`;
169
-
170
- const StyledResultTitle = styled.span<{ theme: Theme }>\`
171
- font-size: \${({ theme }) => theme.fontSizes.text.lg};
172
- font-weight: 500;
173
- color: \${({ theme }) => theme.colors.dark};
174
- display: block;
175
- \`;
176
-
177
- const StyledResultMeta = styled.span<{ theme: Theme }>\`
178
- font-size: \${({ theme }) => theme.fontSizes.small.lg};
179
- color: \${({ theme }) => theme.colors.gray};
180
- display: block;
181
- margin-top: 2px;
182
- \`;
183
-
184
- const StyledSnippet = styled.span<{ theme: Theme }>\`
185
- font-size: \${({ theme }) => theme.fontSizes.small.lg};
186
- color: \${({ theme }) => theme.colors.grayDark};
187
- display: block;
188
- margin-top: 4px;
189
- line-height: 1.4;
190
- overflow: hidden;
191
- text-overflow: ellipsis;
192
- white-space: nowrap;
193
-
194
- & mark {
195
- background: \${({ theme }) => rgba(theme.colors.primaryLight, 0.35)};
196
- color: inherit;
197
- border-radius: 4px;
198
- padding: 0 1px;
199
- }
200
- \`;
201
-
202
- const StyledEmpty = styled.div<{ theme: Theme }>\`
203
- padding: 20px 20px 12px;
204
- min-height: 40px;
205
- display: flex;
206
- align-items: center;
207
- justify-content: center;
208
- text-align: center;
209
- font-size: \${({ theme }) => theme.fontSizes.small.lg};
210
- color: \${({ theme }) => theme.colors.gray};
211
- \`;
212
-
213
43
  const StyledKbd = styled.kbd<{ theme: Theme }>\`
214
44
  font-size: 11px;
215
45
  font-family: inherit;
@@ -248,25 +78,6 @@ const StyledSearchButton = styled.button<{ theme: Theme }>\`
248
78
  }
249
79
  \`;
250
80
 
251
- function escapeHtml(str: string): string {
252
- return str
253
- .replace(/&/g, "&amp;")
254
- .replace(/</g, "&lt;")
255
- .replace(/>/g, "&gt;")
256
- .replace(/"/g, "&quot;");
257
- }
258
-
259
- function highlightMatch(snippet: string, query: string): string {
260
- const escaped = escapeHtml(snippet);
261
- if (!query.trim()) return escaped;
262
- const q = escapeHtml(query.trim());
263
- const regex = new RegExp(
264
- \`(\${q.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")})\`,
265
- "gi",
266
- );
267
- return escaped.replace(regex, "<mark>$1</mark>");
268
- }
269
-
270
81
  function SearchProvider({
271
82
  pages,
272
83
  sections,
@@ -284,7 +95,7 @@ function SearchProvider({
284
95
  const [isSearching, setIsSearching] = useState(false);
285
96
  const inputRef = useRef<HTMLInputElement>(null);
286
97
  const resultsRef = useRef<HTMLUListElement>(null);
287
- const closingTimer = useRef<ReturnType<typeof setTimeout>>(null);
98
+ const closingRef = useRef(false);
288
99
  const abortRef = useRef<AbortController | null>(null);
289
100
  const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
290
101
  const router = useRouter();
@@ -298,23 +109,27 @@ function SearchProvider({
298
109
  }, [sections]);
299
110
 
300
111
  const openSearch = useCallback(() => {
301
- if (closingTimer.current) clearTimeout(closingTimer.current);
112
+ closingRef.current = false;
302
113
  setIsClosing(false);
303
114
  setIsVisible(true);
304
115
  }, []);
305
116
 
306
117
  const closeSearch = useCallback(() => {
118
+ closingRef.current = true;
307
119
  setIsClosing(true);
308
120
  if (abortRef.current) abortRef.current.abort();
309
121
  if (debounceRef.current) clearTimeout(debounceRef.current);
310
- closingTimer.current = setTimeout(() => {
311
- setIsVisible(false);
312
- setIsClosing(false);
313
- setQuery("");
314
- setActiveIndex(0);
315
- setContentResults([]);
316
- setIsSearching(false);
317
- }, ANIMATION_MS);
122
+ }, []);
123
+
124
+ const handleCloseAnimationEnd = useCallback(() => {
125
+ if (!closingRef.current) return;
126
+ closingRef.current = false;
127
+ setIsVisible(false);
128
+ setIsClosing(false);
129
+ setQuery("");
130
+ setActiveIndex(0);
131
+ setContentResults([]);
132
+ setIsSearching(false);
318
133
  }, []);
319
134
 
320
135
  // Instant title/description filtering
@@ -423,13 +238,6 @@ function SearchProvider({
423
238
  return () => document.removeEventListener("keydown", handleKeyDown);
424
239
  }, [closeSearch, openSearch]);
425
240
 
426
- // Focus input on open
427
- useEffect(() => {
428
- if (isVisible && !isClosing) {
429
- setTimeout(() => inputRef.current?.focus(), 10);
430
- }
431
- }, [isVisible, isClosing]);
432
-
433
241
  // Scroll active item into view
434
242
  useEffect(() => {
435
243
  if (!resultsRef.current) return;
@@ -458,60 +266,22 @@ function SearchProvider({
458
266
  <SearchContext.Provider value={{ openSearch }}>
459
267
  {children}
460
268
  {isVisible && (
461
- <StyledBackdrop $isClosing={isClosing} onClick={closeSearch}>
462
- <StyledModal
463
- $isClosing={isClosing}
464
- onClick={(e) => e.stopPropagation()}
465
- >
466
- <StyledInputWrapper>
467
- <Search size={18} />
468
- <StyledInput
469
- ref={inputRef}
470
- value={query}
471
- onChange={(e) => {
472
- setQuery(e.target.value);
473
- setActiveIndex(0);
474
- }}
475
- onKeyDown={handleKeyDown}
476
- placeholder="Search docs..."
477
- autoComplete="off"
478
- spellCheck={false}
479
- />
480
- <StyledKbd>Esc</StyledKbd>
481
- </StyledInputWrapper>
482
- {merged.length > 0 ? (
483
- <StyledResults ref={resultsRef}>
484
- {merged.map((result, index) => (
485
- <StyledResultItem
486
- key={result.page.slug + result.page.section}
487
- $isActive={index === activeIndex}
488
- onClick={() => navigate(result.page.slug)}
489
- onMouseEnter={() => setActiveIndex(index)}
490
- >
491
- <StyledResultTitle>{result.page.title}</StyledResultTitle>
492
- <StyledResultMeta>
493
- {result.page.section
494
- ? \`\${sectionLabels[result.page.section] || result.page.section} / \`
495
- : ""}
496
- {result.page.category}
497
- </StyledResultMeta>
498
- {result.snippet && (
499
- <StyledSnippet
500
- dangerouslySetInnerHTML={{
501
- __html: highlightMatch(result.snippet, query),
502
- }}
503
- />
504
- )}
505
- </StyledResultItem>
506
- ))}
507
- </StyledResults>
508
- ) : (
509
- <StyledEmpty>
510
- {isSearching ? <Spinner size={18} /> : "No results found"}
511
- </StyledEmpty>
512
- )}
513
- </StyledModal>
514
- </StyledBackdrop>
269
+ <SearchModalContent
270
+ isClosing={isClosing}
271
+ closeSearch={closeSearch}
272
+ onCloseAnimationEnd={handleCloseAnimationEnd}
273
+ query={query}
274
+ setQuery={setQuery}
275
+ activeIndex={activeIndex}
276
+ setActiveIndex={setActiveIndex}
277
+ inputRef={inputRef}
278
+ resultsRef={resultsRef}
279
+ onKeyDown={handleKeyDown}
280
+ merged={merged}
281
+ sectionLabels={sectionLabels}
282
+ isSearching={isSearching}
283
+ navigate={navigate}
284
+ />
515
285
  )}
516
286
  </SearchContext.Provider>
517
287
  );
@@ -0,0 +1 @@
1
+ export declare const searchModalContentTemplate = "\"use client\";\nimport React, { useCallback } from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Search } from \"lucide-react\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { Spinner } from \"@/components/Spinner\";\n\nexport interface PageItem {\n slug: string;\n title: string;\n description?: string;\n category: string;\n section?: string;\n}\n\nexport interface MergedResult {\n page: PageItem;\n snippet?: string;\n}\n\nexport interface SearchModalContentProps {\n isClosing: boolean;\n closeSearch: () => void;\n onCloseAnimationEnd: () => void;\n query: string;\n setQuery: (q: string) => void;\n activeIndex: number;\n setActiveIndex: (i: number | ((prev: number) => number)) => void;\n inputRef: React.RefObject<HTMLInputElement | null>;\n resultsRef: React.RefObject<HTMLUListElement | null>;\n onKeyDown: (e: React.KeyboardEvent) => void;\n merged: MergedResult[];\n sectionLabels: Record<string, string>;\n isSearching: boolean;\n navigate: (slug: string) => void;\n}\n\nconst ANIMATION_MS = 150;\n\nconst backdropIn = keyframes`\n from { opacity: 0; }\n to { opacity: 1; }\n`;\n\nconst backdropOut = keyframes`\n from { opacity: 1; }\n to { opacity: 0; }\n`;\n\nconst modalIn = keyframes`\n from { opacity: 0; transform: scale(0.96) translateY(-8px); }\n to { opacity: 1; transform: scale(1) translateY(0); }\n`;\n\nconst modalOut = keyframes`\n from { opacity: 1; transform: scale(1) translateY(0); }\n to { opacity: 0; transform: scale(0.96) translateY(-8px); }\n`;\n\nconst StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>`\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: ${({ theme }) =>\n rgba(theme.isDark ? theme.colors.light : theme.colors.dark, 0.5)};\n backdrop-filter: blur(4px);\n -webkit-backdrop-filter: blur(4px);\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding: 20px;\n animation: ${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n padding: 120px 20px 20px 20px;\n }\n`;\n\nconst StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.xs};\n width: 100%;\n max-width: 560px;\n max-height: calc(100dvh - 40px);\n display: flex;\n flex-direction: column;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding-bottom: 8px;\n animation: ${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n max-height: calc(100dvh - 240px);\n }\n`;\n\nconst StyledInputWrapper = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n flex-shrink: 0;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n & svg.lucide {\n color: ${({ theme }) => theme.colors.gray};\n flex-shrink: 0;\n }\n`;\n\nconst StyledInput = styled.input<{ theme: Theme }>`\n flex: 1;\n border: none;\n outline: none;\n background: transparent;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n color: ${({ theme }) => theme.colors.dark};\n font-family: inherit;\n\n &::placeholder {\n color: ${({ theme }) => theme.colors.gray};\n }\n`;\n\nconst StyledResults = styled.ul<{ theme: Theme }>`\n list-style: none;\n margin: 8px 0 0 0;\n padding: 0 8px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n -webkit-overflow-scrolling: touch;\n\n &::-webkit-scrollbar {\n display: none;\n }\n`;\n\nconst StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>`\n padding: 10px 12px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n transition: background 0.15s ease;\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n background: ${rgba(theme.colors.primaryLight, 0.2)};\n `}\n\n &:hover {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.15)};\n }\n`;\n\nconst StyledResultTitle = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.dark};\n display: block;\n`;\n\nconst StyledResultMeta = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n display: block;\n margin-top: 2px;\n`;\n\nconst StyledSnippet = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.grayDark};\n display: block;\n margin-top: 4px;\n line-height: 1.4;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n & mark {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.35)};\n color: inherit;\n border-radius: 4px;\n padding: 0 1px;\n }\n`;\n\nconst StyledEmpty = styled.div<{ theme: Theme }>`\n padding: 20px 20px 12px;\n min-height: 40px;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n`;\n\nconst StyledKbd = styled.kbd<{ theme: Theme }>`\n font-size: 11px;\n font-family: inherit;\n background: ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.grayDark};\n padding: 2px 6px;\n border-radius: 4px;\n margin-left: auto;\n font-weight: 600;\n display: none;\n\n ${mq(\"lg\")} {\n display: initial;\n }\n`;\n\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\");\n}\n\nfunction highlightMatch(snippet: string, query: string): string {\n const escaped = escapeHtml(snippet);\n if (!query.trim()) return escaped;\n const q = escapeHtml(query.trim());\n const regex = new RegExp(\n `(${q.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")})`,\n \"gi\",\n );\n return escaped.replace(regex, \"<mark>$1</mark>\");\n}\n\nexport function SearchModalContent({\n isClosing,\n closeSearch,\n onCloseAnimationEnd,\n query,\n setQuery,\n activeIndex,\n setActiveIndex,\n inputRef,\n resultsRef,\n onKeyDown,\n merged,\n sectionLabels,\n isSearching,\n navigate,\n}: SearchModalContentProps) {\n const setInputRef = useCallback(\n (node: HTMLInputElement | null) => {\n // Sync with parent ref\n if (inputRef) {\n (inputRef as React.RefObject<HTMLInputElement | null>).current = node;\n }\n // Auto-focus on mount\n if (node && !isClosing) {\n node.focus();\n }\n },\n [inputRef, isClosing],\n );\n\n return (\n <StyledBackdrop\n $isClosing={isClosing}\n onClick={closeSearch}\n onAnimationEnd={onCloseAnimationEnd}\n >\n <StyledModal $isClosing={isClosing} onClick={(e) => e.stopPropagation()}>\n <StyledInputWrapper>\n <Search size={18} />\n <StyledInput\n ref={setInputRef}\n value={query}\n onChange={(e) => {\n setQuery(e.target.value);\n setActiveIndex(0);\n }}\n onKeyDown={onKeyDown}\n placeholder=\"Search docs...\"\n autoComplete=\"off\"\n spellCheck={false}\n />\n <StyledKbd>Esc</StyledKbd>\n </StyledInputWrapper>\n {merged.length > 0 ? (\n <StyledResults ref={resultsRef}>\n {merged.map((result, index) => (\n <StyledResultItem\n key={result.page.slug + result.page.section}\n $isActive={index === activeIndex}\n onClick={() => navigate(result.page.slug)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n <StyledResultTitle>{result.page.title}</StyledResultTitle>\n <StyledResultMeta>\n {result.page.section\n ? `${sectionLabels[result.page.section] || result.page.section} / `\n : \"\"}\n {result.page.category}\n </StyledResultMeta>\n {result.snippet && (\n <StyledSnippet\n dangerouslySetInnerHTML={{\n __html: highlightMatch(result.snippet, query),\n }}\n />\n )}\n </StyledResultItem>\n ))}\n </StyledResults>\n ) : (\n <StyledEmpty>\n {isSearching ? <Spinner size={18} /> : \"No results found\"}\n </StyledEmpty>\n )}\n </StyledModal>\n </StyledBackdrop>\n );\n}\n";