doccupine 0.0.83 → 0.0.85

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