@opendocsdev/cli 0.2.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 (78) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +300 -0
  3. package/dist/bin/opendocs.js +712 -0
  4. package/dist/bin/opendocs.js.map +1 -0
  5. package/dist/templates/api-reference.mdx +308 -0
  6. package/dist/templates/components.mdx +286 -0
  7. package/dist/templates/configuration.mdx +190 -0
  8. package/dist/templates/docs.json +27 -0
  9. package/dist/templates/introduction.mdx +25 -0
  10. package/dist/templates/logo.svg +4 -0
  11. package/dist/templates/quickstart.mdx +59 -0
  12. package/dist/templates/writing-content.mdx +236 -0
  13. package/package.json +92 -0
  14. package/src/engine/astro.config.ts +75 -0
  15. package/src/engine/src/components/Analytics.astro +57 -0
  16. package/src/engine/src/components/ApiPlayground.astro +24 -0
  17. package/src/engine/src/components/Callout.astro +66 -0
  18. package/src/engine/src/components/Card.astro +75 -0
  19. package/src/engine/src/components/CardGroup.astro +29 -0
  20. package/src/engine/src/components/CodeGroup.astro +231 -0
  21. package/src/engine/src/components/CopyButton.astro +179 -0
  22. package/src/engine/src/components/Steps.astro +27 -0
  23. package/src/engine/src/components/Tab.astro +21 -0
  24. package/src/engine/src/components/TableOfContents.astro +119 -0
  25. package/src/engine/src/components/Tabs.astro +135 -0
  26. package/src/engine/src/components/index.ts +107 -0
  27. package/src/engine/src/components/react/ApiPlayground/AuthSection.tsx +91 -0
  28. package/src/engine/src/components/react/ApiPlayground/CodeBlock.tsx +66 -0
  29. package/src/engine/src/components/react/ApiPlayground/CodeSnippets.tsx +66 -0
  30. package/src/engine/src/components/react/ApiPlayground/CollapsibleSection.tsx +26 -0
  31. package/src/engine/src/components/react/ApiPlayground/KeyValueEditor.tsx +58 -0
  32. package/src/engine/src/components/react/ApiPlayground/ResponseDisplay.tsx +109 -0
  33. package/src/engine/src/components/react/ApiPlayground/Spinner.tsx +6 -0
  34. package/src/engine/src/components/react/ApiPlayground/constants.ts +16 -0
  35. package/src/engine/src/components/react/ApiPlayground/generators.test.ts +130 -0
  36. package/src/engine/src/components/react/ApiPlayground/generators.ts +75 -0
  37. package/src/engine/src/components/react/ApiPlayground/index.tsx +490 -0
  38. package/src/engine/src/components/react/ApiPlayground/types.ts +35 -0
  39. package/src/engine/src/components/react/Callout.tsx +54 -0
  40. package/src/engine/src/components/react/Card.tsx +48 -0
  41. package/src/engine/src/components/react/CardGroup.tsx +24 -0
  42. package/src/engine/src/components/react/FeedbackWidget.tsx +166 -0
  43. package/src/engine/src/components/react/GitHubLink.tsx +28 -0
  44. package/src/engine/src/components/react/NavigationCard.tsx +53 -0
  45. package/src/engine/src/components/react/PageActions.tsx +124 -0
  46. package/src/engine/src/components/react/PageFooter.tsx +91 -0
  47. package/src/engine/src/components/react/SearchModal.tsx +358 -0
  48. package/src/engine/src/components/react/SearchProvider.tsx +37 -0
  49. package/src/engine/src/components/react/Sidebar.tsx +369 -0
  50. package/src/engine/src/components/react/SidebarSearchTrigger.tsx +57 -0
  51. package/src/engine/src/components/react/Steps.tsx +25 -0
  52. package/src/engine/src/components/react/ThemeToggle.tsx +72 -0
  53. package/src/engine/src/components/react/index.ts +14 -0
  54. package/src/engine/src/env.d.ts +10 -0
  55. package/src/engine/src/layouts/DocsLayout.astro +357 -0
  56. package/src/engine/src/lib/__tests__/markdown.test.ts +124 -0
  57. package/src/engine/src/lib/__tests__/mdx-loader.test.ts +205 -0
  58. package/src/engine/src/lib/config.ts +79 -0
  59. package/src/engine/src/lib/markdown.ts +54 -0
  60. package/src/engine/src/lib/mdx-loader.ts +143 -0
  61. package/src/engine/src/lib/mdx-utils.ts +72 -0
  62. package/src/engine/src/lib/remark-opendocs.ts +195 -0
  63. package/src/engine/src/lib/utils.ts +221 -0
  64. package/src/engine/src/pages/[...slug].astro +115 -0
  65. package/src/engine/src/pages/index.astro +71 -0
  66. package/src/engine/src/scripts/mobile-sidebar.ts +56 -0
  67. package/src/engine/src/scripts/theme-init.ts +25 -0
  68. package/src/engine/src/styles/global.css +703 -0
  69. package/src/engine/tailwind.config.mjs +60 -0
  70. package/src/engine/tsconfig.json +15 -0
  71. package/src/templates/api-reference.mdx +308 -0
  72. package/src/templates/components.mdx +286 -0
  73. package/src/templates/configuration.mdx +190 -0
  74. package/src/templates/docs.json +27 -0
  75. package/src/templates/introduction.mdx +25 -0
  76. package/src/templates/logo.svg +4 -0
  77. package/src/templates/quickstart.mdx +59 -0
  78. package/src/templates/writing-content.mdx +236 -0
@@ -0,0 +1,358 @@
1
+ import { useEffect, useRef, useCallback, useReducer } from "react";
2
+ import { Search } from "lucide-react";
3
+ import { cn } from "../../lib/utils";
4
+
5
+ interface SearchResult {
6
+ url: string;
7
+ title: string;
8
+ excerpt: string;
9
+ breadcrumb?: string[];
10
+ }
11
+
12
+ interface SearchModalProps {
13
+ isOpen: boolean;
14
+ onClose: () => void;
15
+ backend?: string;
16
+ siteId?: string;
17
+ }
18
+
19
+ // Consolidated state with useReducer to avoid useState hell
20
+ interface SearchState {
21
+ query: string;
22
+ results: SearchResult[];
23
+ status: "idle" | "loading" | "error";
24
+ error: string | null;
25
+ selectedIndex: number;
26
+ pagefindLoaded: boolean;
27
+ }
28
+
29
+ type SearchAction =
30
+ | { type: "SET_QUERY"; payload: string }
31
+ | { type: "SEARCH_START" }
32
+ | { type: "SEARCH_SUCCESS"; payload: SearchResult[] }
33
+ | { type: "SEARCH_ERROR"; payload: string }
34
+ | { type: "CLEAR_RESULTS" }
35
+ | { type: "SET_SELECTED_INDEX"; payload: number }
36
+ | { type: "PAGEFIND_LOADED" }
37
+ | { type: "PAGEFIND_ERROR"; payload: string }
38
+ | { type: "RESET" };
39
+
40
+ const initialState: SearchState = {
41
+ query: "",
42
+ results: [],
43
+ status: "idle",
44
+ error: null,
45
+ selectedIndex: 0,
46
+ pagefindLoaded: false,
47
+ };
48
+
49
+ function searchReducer(state: SearchState, action: SearchAction): SearchState {
50
+ switch (action.type) {
51
+ case "SET_QUERY":
52
+ return { ...state, query: action.payload };
53
+ case "SEARCH_START":
54
+ return { ...state, status: "loading" };
55
+ case "SEARCH_SUCCESS":
56
+ // Reset selectedIndex when results change - no useEffect needed
57
+ return { ...state, status: "idle", results: action.payload, selectedIndex: 0 };
58
+ case "SEARCH_ERROR":
59
+ return { ...state, status: "error", error: action.payload };
60
+ case "CLEAR_RESULTS":
61
+ return { ...state, results: [], selectedIndex: 0 };
62
+ case "SET_SELECTED_INDEX":
63
+ return { ...state, selectedIndex: action.payload };
64
+ case "PAGEFIND_LOADED":
65
+ return { ...state, pagefindLoaded: true, error: null };
66
+ case "PAGEFIND_ERROR":
67
+ return { ...state, error: action.payload };
68
+ case "RESET":
69
+ return { ...initialState, pagefindLoaded: state.pagefindLoaded };
70
+ default:
71
+ return state;
72
+ }
73
+ }
74
+
75
+ export function SearchModal({ isOpen, onClose, backend, siteId }: SearchModalProps) {
76
+ const [state, dispatch] = useReducer(searchReducer, initialState);
77
+ const { query, results, status, error, selectedIndex, pagefindLoaded } = state;
78
+
79
+ const inputRef = useRef<HTMLInputElement>(null);
80
+ const resultsRef = useRef<HTMLDivElement>(null);
81
+ const pagefindRef = useRef<any>(null);
82
+
83
+ // Track the last search for analytics (only sent on meaningful actions)
84
+ const lastSearchRef = useRef<{ query: string; resultCount: number } | null>(null);
85
+ const hasTrackedRef = useRef(false);
86
+
87
+ const loadPagefind = async () => {
88
+ try {
89
+ // Use Function constructor to create a truly dynamic import that Vite won't analyze
90
+ const importPagefind = new Function(
91
+ 'return import("/pagefind/pagefind.js")'
92
+ ) as () => Promise<any>;
93
+
94
+ const pagefind = await importPagefind();
95
+ await pagefind.init();
96
+ pagefindRef.current = pagefind;
97
+ dispatch({ type: "PAGEFIND_LOADED" });
98
+ } catch {
99
+ dispatch({ type: "PAGEFIND_ERROR", payload: "Search not available. Run build to enable search." });
100
+ }
101
+ };
102
+
103
+ // Track search query for analytics - only tracks once per search session
104
+ const trackSearch = useCallback(() => {
105
+ if (!backend || !siteId) return;
106
+ if (hasTrackedRef.current) return;
107
+ if (!lastSearchRef.current) return;
108
+
109
+ const { query: searchQuery, resultCount } = lastSearchRef.current;
110
+ if (!searchQuery.trim()) return;
111
+
112
+ hasTrackedRef.current = true;
113
+
114
+ fetch(`${backend}/api/analytics/sq`, {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({
118
+ siteId,
119
+ query: searchQuery,
120
+ resultCount,
121
+ }),
122
+ }).catch(() => {
123
+ // Silently fail - analytics should not break search
124
+ });
125
+ }, [backend, siteId]);
126
+
127
+ // Handle close with analytics tracking
128
+ const handleClose = useCallback(() => {
129
+ trackSearch();
130
+ onClose();
131
+ }, [onClose, trackSearch]);
132
+
133
+ // Handle result selection with analytics tracking
134
+ const handleResultSelect = useCallback((url: string) => {
135
+ trackSearch();
136
+ window.location.href = url;
137
+ }, [trackSearch]);
138
+
139
+ const performSearch = useCallback(async (searchQuery: string) => {
140
+ if (!searchQuery.trim()) {
141
+ dispatch({ type: "CLEAR_RESULTS" });
142
+ return;
143
+ }
144
+
145
+ if (!pagefindRef.current) {
146
+ return;
147
+ }
148
+
149
+ dispatch({ type: "SEARCH_START" });
150
+ try {
151
+ const searchResults = await pagefindRef.current.search(searchQuery);
152
+ const formattedResults: SearchResult[] = await Promise.all(
153
+ searchResults.results.slice(0, 10).map(async (result: any) => {
154
+ const data = await result.data();
155
+ return {
156
+ url: data.url,
157
+ title: data.meta?.title || data.url,
158
+ excerpt: data.excerpt || "",
159
+ breadcrumb: data.url.split("/").filter(Boolean).slice(0, -1),
160
+ };
161
+ })
162
+ );
163
+ dispatch({ type: "SEARCH_SUCCESS", payload: formattedResults });
164
+ lastSearchRef.current = { query: searchQuery, resultCount: searchResults.results.length };
165
+ } catch (e) {
166
+ console.error("Search error:", e);
167
+ dispatch({ type: "SEARCH_ERROR", payload: "Search failed" });
168
+ }
169
+ }, []);
170
+
171
+ // Load Pagefind on first open
172
+ useEffect(() => {
173
+ if (isOpen && !pagefindLoaded && !pagefindRef.current) {
174
+ loadPagefind();
175
+ }
176
+ }, [isOpen, pagefindLoaded]);
177
+
178
+ // Reset state and focus input when modal opens
179
+ useEffect(() => {
180
+ if (isOpen) {
181
+ dispatch({ type: "RESET" });
182
+ lastSearchRef.current = null;
183
+ hasTrackedRef.current = false;
184
+ // Small delay to ensure modal is rendered before focusing
185
+ const timerId = setTimeout(() => inputRef.current?.focus(), 50);
186
+ return () => clearTimeout(timerId);
187
+ }
188
+ }, [isOpen]);
189
+
190
+ // Scroll selected item into view
191
+ useEffect(() => {
192
+ if (resultsRef.current && results.length > 0) {
193
+ const selectedEl = resultsRef.current.querySelector(`[data-index="${selectedIndex}"]`);
194
+ selectedEl?.scrollIntoView({ block: "nearest" });
195
+ }
196
+ }, [selectedIndex, results.length]);
197
+
198
+ // Debounced search - this is a valid useEffect for external system sync (pagefind)
199
+ useEffect(() => {
200
+ const timer = setTimeout(() => {
201
+ performSearch(query);
202
+ }, 150);
203
+ return () => clearTimeout(timer);
204
+ }, [query, performSearch]);
205
+
206
+ // Keyboard navigation - all handled in a single event handler, no separate useEffect
207
+ const handleKeyDown = (e: React.KeyboardEvent) => {
208
+ switch (e.key) {
209
+ case "ArrowDown":
210
+ e.preventDefault();
211
+ dispatch({ type: "SET_SELECTED_INDEX", payload: Math.min(selectedIndex + 1, results.length - 1) });
212
+ break;
213
+ case "ArrowUp":
214
+ e.preventDefault();
215
+ dispatch({ type: "SET_SELECTED_INDEX", payload: Math.max(selectedIndex - 1, 0) });
216
+ break;
217
+ case "Enter":
218
+ e.preventDefault();
219
+ if (results[selectedIndex]) {
220
+ handleResultSelect(results[selectedIndex].url);
221
+ }
222
+ break;
223
+ case "Escape":
224
+ e.preventDefault();
225
+ handleClose();
226
+ break;
227
+ }
228
+ };
229
+
230
+ if (!isOpen) return null;
231
+
232
+ const isLoading = status === "loading";
233
+
234
+ return (
235
+ <div
236
+ className="fixed inset-0 z-50"
237
+ role="dialog"
238
+ aria-modal="true"
239
+ onKeyDown={(e) => {
240
+ // Global escape handler for clicking outside input
241
+ if (e.key === "Escape") {
242
+ e.preventDefault();
243
+ handleClose();
244
+ }
245
+ }}
246
+ >
247
+ {/* Backdrop */}
248
+ <div
249
+ className="absolute inset-0 bg-black/50 backdrop-blur-sm"
250
+ onClick={handleClose}
251
+ aria-hidden="true"
252
+ />
253
+
254
+ {/* Modal */}
255
+ <div className="relative flex flex-col max-w-2xl w-[calc(100%-1rem)] sm:w-[calc(100%-2rem)] mx-auto mt-4 sm:mt-[10vh] max-h-[85vh] sm:max-h-[70vh] bg-[var(--color-surface)] rounded-xl shadow-2xl overflow-hidden border border-[var(--color-border)]">
256
+ {/* Search input */}
257
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border)]">
258
+ <Search className="w-5 h-5 text-[var(--color-muted)] flex-shrink-0" />
259
+ <input
260
+ ref={inputRef}
261
+ type="text"
262
+ value={query}
263
+ onChange={(e) => dispatch({ type: "SET_QUERY", payload: e.target.value })}
264
+ onKeyDown={handleKeyDown}
265
+ placeholder="Search documentation..."
266
+ className="flex-1 bg-transparent text-[var(--color-foreground)] text-base placeholder:text-[var(--color-muted)] focus:outline-none"
267
+ autoComplete="off"
268
+ autoCorrect="off"
269
+ autoCapitalize="off"
270
+ spellCheck="false"
271
+ />
272
+ <kbd className="hidden sm:flex items-center px-2 py-1 text-xs font-medium text-[var(--color-muted-foreground)] bg-[var(--color-surface-sunken)] border border-[var(--color-border)] rounded">
273
+ ESC
274
+ </kbd>
275
+ </div>
276
+
277
+ {/* Results */}
278
+ <div ref={resultsRef} className="flex-1 overflow-y-auto">
279
+ {error ? (
280
+ <div className="p-8 text-center">
281
+ <p className="text-sm text-[var(--color-muted)]">{error}</p>
282
+ </div>
283
+ ) : isLoading ? (
284
+ <div className="p-8 text-center">
285
+ <div className="inline-block w-5 h-5 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
286
+ </div>
287
+ ) : results.length > 0 ? (
288
+ <ul className="py-2">
289
+ {results.map((result, index) => (
290
+ <li key={result.url} data-index={index}>
291
+ <a
292
+ href={result.url}
293
+ onClick={(e) => {
294
+ e.preventDefault();
295
+ handleResultSelect(result.url);
296
+ }}
297
+ className={cn(
298
+ "block px-4 py-3 transition-colors",
299
+ index === selectedIndex
300
+ ? "bg-[var(--color-primary-light)]"
301
+ : "hover:bg-[var(--color-surface-sunken)]"
302
+ )}
303
+ >
304
+ {result.breadcrumb && result.breadcrumb.length > 0 && (
305
+ <div className="flex items-center gap-1 text-xs text-[var(--color-muted)] mb-1">
306
+ {result.breadcrumb.map((crumb, i) => (
307
+ <span key={i} className="flex items-center gap-1">
308
+ {i > 0 && <span className="text-[var(--color-border)]">›</span>}
309
+ <span className="capitalize">{crumb.replace(/-/g, " ")}</span>
310
+ </span>
311
+ ))}
312
+ </div>
313
+ )}
314
+ <div className="font-medium text-[var(--color-foreground)]">
315
+ {result.title}
316
+ </div>
317
+ {result.excerpt && (
318
+ <p
319
+ className="mt-1 text-sm text-[var(--color-muted)] line-clamp-2 [&_mark]:bg-[var(--color-primary-light)] [&_mark]:text-[var(--color-primary)] [&_mark]:rounded [&_mark]:px-0.5"
320
+ dangerouslySetInnerHTML={{ __html: result.excerpt }}
321
+ />
322
+ )}
323
+ </a>
324
+ </li>
325
+ ))}
326
+ </ul>
327
+ ) : query.trim() ? (
328
+ <div className="p-8 text-center">
329
+ <p className="text-sm text-[var(--color-muted)]">No results found for "{query}"</p>
330
+ </div>
331
+ ) : (
332
+ <div className="p-8 text-center">
333
+ <p className="text-sm text-[var(--color-muted)]">Start typing to search...</p>
334
+ </div>
335
+ )}
336
+ </div>
337
+
338
+ {/* Footer */}
339
+ <div className="flex items-center justify-between px-4 py-2 border-t border-[var(--color-border)] text-xs text-[var(--color-muted-foreground)]">
340
+ <div className="flex items-center gap-3">
341
+ <span className="flex items-center gap-1">
342
+ <kbd className="px-1.5 py-0.5 bg-[var(--color-surface-sunken)] border border-[var(--color-border)] rounded text-[10px]">↑</kbd>
343
+ <kbd className="px-1.5 py-0.5 bg-[var(--color-surface-sunken)] border border-[var(--color-border)] rounded text-[10px]">↓</kbd>
344
+ <span className="ml-1">to navigate</span>
345
+ </span>
346
+ <span className="flex items-center gap-1">
347
+ <kbd className="px-1.5 py-0.5 bg-[var(--color-surface-sunken)] border border-[var(--color-border)] rounded text-[10px]">↵</kbd>
348
+ <span className="ml-1">to select</span>
349
+ </span>
350
+ </div>
351
+ <span>Powered by Pagefind</span>
352
+ </div>
353
+ </div>
354
+ </div>
355
+ );
356
+ }
357
+
358
+ export default SearchModal;
@@ -0,0 +1,37 @@
1
+ import { useEffect, useState, useCallback } from "react";
2
+ import { SearchModal } from "./SearchModal";
3
+
4
+ interface SearchProviderProps {
5
+ backend?: string;
6
+ siteId?: string;
7
+ }
8
+
9
+ export function SearchProvider({ backend, siteId }: SearchProviderProps) {
10
+ const [isOpen, setIsOpen] = useState(false);
11
+
12
+ const openSearch = useCallback(() => setIsOpen(true), []);
13
+ const closeSearch = useCallback(() => setIsOpen(false), []);
14
+
15
+ // Global keyboard shortcut: Cmd+K / Ctrl+K
16
+ useEffect(() => {
17
+ const handleKeyDown = (e: KeyboardEvent) => {
18
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
19
+ e.preventDefault();
20
+ setIsOpen((prev) => !prev);
21
+ }
22
+ };
23
+
24
+ document.addEventListener("keydown", handleKeyDown);
25
+ return () => document.removeEventListener("keydown", handleKeyDown);
26
+ }, []);
27
+
28
+ // Listen for custom event from search triggers
29
+ useEffect(() => {
30
+ document.addEventListener("open-search", openSearch);
31
+ return () => document.removeEventListener("open-search", openSearch);
32
+ }, [openSearch]);
33
+
34
+ return <SearchModal isOpen={isOpen} onClose={closeSearch} backend={backend} siteId={siteId} />;
35
+ }
36
+
37
+ export default SearchProvider;