@cyguin/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.
@@ -0,0 +1,521 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
+ import { marked } from 'marked';
5
+ import type { DocArticle, DocsWidgetProps } from '../types.js';
6
+
7
+ marked.setOptions({ breaks: true, gfm: true });
8
+
9
+ function HelpIcon() {
10
+ return (
11
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
12
+ <circle cx="12" cy="12" r="10" />
13
+ <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
14
+ <path d="M12 17h.01" />
15
+ </svg>
16
+ );
17
+ }
18
+
19
+ function SearchIcon() {
20
+ return (
21
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
22
+ <circle cx="11" cy="11" r="8" />
23
+ <path d="m21 21-4.35-4.35" />
24
+ </svg>
25
+ );
26
+ }
27
+
28
+ function CloseIcon() {
29
+ return (
30
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
31
+ <path d="M18 6 6 18" />
32
+ <path d="m6 6 12 12" />
33
+ </svg>
34
+ );
35
+ }
36
+
37
+ function ChevronRightIcon({ expanded }: { expanded: boolean }) {
38
+ return (
39
+ <svg
40
+ width="14"
41
+ height="14"
42
+ viewBox="0 0 24 24"
43
+ fill="none"
44
+ stroke="currentColor"
45
+ strokeWidth="2"
46
+ strokeLinecap="round"
47
+ strokeLinejoin="round"
48
+ style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}
49
+ >
50
+ <path d="m9 18 6-6-6-6" />
51
+ </svg>
52
+ );
53
+ }
54
+
55
+ function BackIcon() {
56
+ return (
57
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
58
+ <path d="m15 18-6-6 6-6" />
59
+ </svg>
60
+ );
61
+ }
62
+
63
+ function renderMarkdown(md: string): string {
64
+ return marked.parse(md) as string;
65
+ }
66
+
67
+ interface ArticleGroup {
68
+ section: string;
69
+ articles: DocArticle[];
70
+ }
71
+
72
+ function groupBySection(articles: DocArticle[]): ArticleGroup[] {
73
+ const map = new Map<string, DocArticle[]>();
74
+ for (const article of articles) {
75
+ const list = map.get(article.section) ?? [];
76
+ list.push(article);
77
+ map.set(article.section, list);
78
+ }
79
+ return Array.from(map.entries())
80
+ .map(([section, arts]) => ({ section, articles: arts.sort((a, b) => a.article_order - b.article_order) }))
81
+ .sort((a, b) => a.section.localeCompare(b.section));
82
+ }
83
+
84
+ interface DocsTriggerProps {
85
+ label: string;
86
+ onClick: () => void;
87
+ }
88
+
89
+ function DocsTrigger({ label, onClick }: DocsTriggerProps) {
90
+ return (
91
+ <button
92
+ onClick={onClick}
93
+ aria-label={label}
94
+ style={{
95
+ position: 'fixed',
96
+ bottom: '24px',
97
+ right: '24px',
98
+ zIndex: 9998,
99
+ width: '48px',
100
+ height: '48px',
101
+ borderRadius: 'var(--cyguin-docs-radius)',
102
+ background: 'var(--cyguin-docs-accent)',
103
+ color: 'var(--cyguin-accent-fg, #0a0a0a)',
104
+ border: 'none',
105
+ cursor: 'pointer',
106
+ display: 'flex',
107
+ alignItems: 'center',
108
+ justifyContent: 'center',
109
+ boxShadow: 'var(--cyguin-docs-shadow)',
110
+ fontWeight: 600,
111
+ fontSize: '14px',
112
+ fontFamily: 'var(--cyguin-docs-font)',
113
+ transition: 'opacity 0.15s',
114
+ }}
115
+ >
116
+ <HelpIcon />
117
+ </button>
118
+ );
119
+ }
120
+
121
+ interface DocsSearchProps {
122
+ value: string;
123
+ onChange: (value: string) => void;
124
+ }
125
+
126
+ function DocsSearch({ value, onChange }: DocsSearchProps) {
127
+ const ref = useRef<HTMLInputElement>(null);
128
+
129
+ useEffect(() => {
130
+ const handler = (e: KeyboardEvent) => {
131
+ if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
132
+ const active = document.activeElement;
133
+ const widget = document.getElementById('cyguin-docs-widget');
134
+ if (active && widget?.contains(active)) return;
135
+ e.preventDefault();
136
+ ref.current?.focus();
137
+ }
138
+ };
139
+ document.addEventListener('keydown', handler);
140
+ return () => document.removeEventListener('keydown', handler);
141
+ }, []);
142
+
143
+ return (
144
+ <div style={{ position: 'relative', padding: '12px 12px 8px' }}>
145
+ <SearchIcon />
146
+ <input
147
+ ref={ref}
148
+ type="text"
149
+ value={value}
150
+ onChange={(e) => onChange(e.target.value)}
151
+ placeholder="Search docs... (press / to focus)"
152
+ aria-label="Search documentation"
153
+ style={{
154
+ width: '100%',
155
+ padding: '8px 8px 8px 32px',
156
+ borderRadius: 'var(--cyguin-docs-radius)',
157
+ border: '1px solid var(--cyguin-docs-border)',
158
+ background: 'var(--cyguin-bg-subtle, #f5f5f5)',
159
+ color: 'var(--cyguin-docs-text)',
160
+ fontSize: '14px',
161
+ fontFamily: 'var(--cyguin-docs-font)',
162
+ boxSizing: 'border-box',
163
+ outline: 'none',
164
+ }}
165
+ onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--cyguin-border-focus, #f5a800)'; }}
166
+ onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--cyguin-docs-border)'; }}
167
+ />
168
+ </div>
169
+ );
170
+ }
171
+
172
+ interface DocsNavProps {
173
+ groups: ArticleGroup[];
174
+ selectedId: string | null;
175
+ selectedIndex: number;
176
+ onSelectArticle: (id: string, index: number) => void;
177
+ searchQuery: string;
178
+ }
179
+
180
+ function DocsNav({ groups, selectedId, selectedIndex, onSelectArticle, searchQuery }: DocsNavProps) {
181
+ const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(groups.map((g) => g.section)));
182
+ const navRef = useRef<HTMLDivElement>(null);
183
+ const flatArticles = groups.flatMap((g) => g.articles);
184
+ const activeIndexRef = useRef(-1);
185
+
186
+ useEffect(() => {
187
+ activeIndexRef.current = selectedIndex;
188
+ }, [selectedIndex]);
189
+
190
+ useEffect(() => {
191
+ const handler = (e: KeyboardEvent) => {
192
+ if (e.key === 'ArrowDown') {
193
+ e.preventDefault();
194
+ const next = Math.min(activeIndexRef.current + 1, flatArticles.length - 1);
195
+ onSelectArticle(flatArticles[next].id, next);
196
+ scrollToSelected(next);
197
+ } else if (e.key === 'ArrowUp') {
198
+ e.preventDefault();
199
+ const prev = Math.max(activeIndexRef.current - 1, 0);
200
+ onSelectArticle(flatArticles[prev].id, prev);
201
+ scrollToSelected(prev);
202
+ } else if (e.key === 'Enter' && selectedId) {
203
+ e.preventDefault();
204
+ const idx = flatArticles.findIndex((a) => a.id === selectedId);
205
+ if (idx !== -1) onSelectArticle(selectedId, idx);
206
+ }
207
+ };
208
+ const widget = document.getElementById('cyguin-docs-widget');
209
+ widget?.addEventListener('keydown', handler);
210
+ return () => widget?.removeEventListener('keydown', handler);
211
+ }, [flatArticles, selectedId, onSelectArticle]);
212
+
213
+ function scrollToSelected(index: number) {
214
+ const buttons = navRef.current?.querySelectorAll<HTMLButtonElement>('[data-article-btn]');
215
+ buttons?.[index]?.scrollIntoView({ block: 'nearest' });
216
+ }
217
+
218
+ function toggleSection(section: string) {
219
+ setExpandedSections((prev) => {
220
+ const next = new Set(prev);
221
+ if (next.has(section)) next.delete(section);
222
+ else next.add(section);
223
+ return next;
224
+ });
225
+ }
226
+
227
+ let articleIdx = 0;
228
+ return (
229
+ <div ref={navRef} style={{ overflowY: 'auto', flex: 1, borderRight: '1px solid var(--cyguin-docs-border)' }}>
230
+ {groups.length === 0 && (
231
+ <p style={{ padding: '16px', color: 'var(--cyguin-docs-muted)', fontSize: '13px', textAlign: 'center', fontFamily: 'var(--cyguin-docs-font)' }}>
232
+ {searchQuery ? 'No results found' : 'No articles yet'}
233
+ </p>
234
+ )}
235
+ {groups.map((group) => {
236
+ const isExpanded = expandedSections.has(group.section);
237
+ return (
238
+ <div key={group.section}>
239
+ <button
240
+ onClick={() => toggleSection(group.section)}
241
+ style={{
242
+ width: '100%',
243
+ padding: '8px 12px',
244
+ background: 'none',
245
+ border: 'none',
246
+ cursor: 'pointer',
247
+ display: 'flex',
248
+ alignItems: 'center',
249
+ gap: '6px',
250
+ color: 'var(--cyguin-docs-text)',
251
+ fontSize: '12px',
252
+ fontWeight: 700,
253
+ fontFamily: 'var(--cyguin-docs-font)',
254
+ textTransform: 'uppercase',
255
+ letterSpacing: '0.05em',
256
+ }}
257
+ >
258
+ <ChevronRightIcon expanded={isExpanded} />
259
+ {group.section}
260
+ </button>
261
+ {isExpanded &&
262
+ group.articles.map((article) => {
263
+ const idx = articleIdx++;
264
+ const isSelected = article.id === selectedId;
265
+ return (
266
+ <button
267
+ key={article.id}
268
+ data-article-btn
269
+ onClick={() => onSelectArticle(article.id, idx)}
270
+ style={{
271
+ width: '100%',
272
+ padding: '6px 12px 6px 28px',
273
+ background: isSelected ? 'var(--cyguin-docs-accent)' : 'none',
274
+ color: isSelected ? 'var(--cyguin-accent-fg, #0a0a0a)' : 'var(--cyguin-docs-text)',
275
+ border: 'none',
276
+ cursor: 'pointer',
277
+ textAlign: 'left',
278
+ fontSize: '13px',
279
+ fontFamily: 'var(--cyguin-docs-font)',
280
+ borderRadius: 'var(--cyguin-docs-radius)',
281
+ margin: '1px 6px',
282
+ whiteSpace: 'nowrap',
283
+ overflow: 'hidden',
284
+ textOverflow: 'ellipsis',
285
+ }}
286
+ >
287
+ {article.title}
288
+ </button>
289
+ );
290
+ })}
291
+ </div>
292
+ );
293
+ })}
294
+ </div>
295
+ );
296
+ }
297
+
298
+ interface DocsArticleViewProps {
299
+ article: DocArticle | null;
300
+ onBack: () => void;
301
+ }
302
+
303
+ function DocsArticleView({ article, onBack }: DocsArticleViewProps) {
304
+ const contentRef = useRef<HTMLDivElement>(null);
305
+
306
+ useEffect(() => {
307
+ if (contentRef.current) {
308
+ contentRef.current.scrollTop = 0;
309
+ }
310
+ }, [article?.id]);
311
+
312
+ if (!article) {
313
+ return (
314
+ <div style={{ flex: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--cyguin-docs-muted)', fontSize: '14px', fontFamily: 'var(--cyguin-docs-font)' }}>
315
+ Select an article to read
316
+ </div>
317
+ );
318
+ }
319
+
320
+ return (
321
+ <div style={{ flex: 2, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
322
+ <div style={{ padding: '12px 16px', borderBottom: '1px solid var(--cyguin-docs-border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
323
+ <button
324
+ onClick={onBack}
325
+ aria-label="Back to list"
326
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--cyguin-docs-muted)', display: 'flex', alignItems: 'center', padding: '4px', borderRadius: 'var(--cyguin-docs-radius)' }}
327
+ >
328
+ <BackIcon />
329
+ </button>
330
+ <span style={{ fontSize: '15px', fontWeight: 700, color: 'var(--cyguin-docs-text)', fontFamily: 'var(--cyguin-docs-font)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
331
+ {article.title}
332
+ </span>
333
+ </div>
334
+ <div
335
+ ref={contentRef}
336
+ style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', fontSize: '14px', lineHeight: 1.7, color: 'var(--cyguin-docs-text)', fontFamily: 'var(--cyguin-docs-font)' }}
337
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(article.body_md) }}
338
+ />
339
+ </div>
340
+ );
341
+ }
342
+
343
+ interface DocsPanelProps {
344
+ mode: 'modal' | 'sidebar';
345
+ open: boolean;
346
+ onClose: () => void;
347
+ groups: ArticleGroup[];
348
+ selectedId: string | null;
349
+ selectedIndex: number;
350
+ searchQuery: string;
351
+ onSearchChange: (q: string) => void;
352
+ onSelectArticle: (id: string, index: number) => void;
353
+ onBack: () => void;
354
+ article: DocArticle | null;
355
+ }
356
+
357
+ function DocsPanel({ mode, open, onClose, groups, selectedId, selectedIndex, searchQuery, onSearchChange, onSelectArticle, onBack, article }: DocsPanelProps) {
358
+ const isMobile = typeof window !== 'undefined' && window.innerWidth < 640;
359
+
360
+ const panelStyle: React.CSSProperties = {
361
+ position: 'fixed',
362
+ top: 0,
363
+ right: 0,
364
+ bottom: 0,
365
+ width: isMobile ? '100vw' : mode === 'sidebar' ? '420px' : '680px',
366
+ maxWidth: '100vw',
367
+ background: 'var(--cyguin-docs-bg)',
368
+ borderRadius: mode === 'sidebar' || isMobile ? 0 : 'var(--cyguin-docs-radius)',
369
+ boxShadow: mode === 'modal' ? 'var(--cyguin-docs-shadow)' : 'none',
370
+ border: mode === 'modal' && !isMobile ? '1px solid var(--cyguin-docs-border)' : 'none',
371
+ display: 'flex',
372
+ flexDirection: 'column',
373
+ zIndex: 9999,
374
+ transform: open ? 'translateX(0)' : 'translateX(100%)',
375
+ transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
376
+ fontFamily: 'var(--cyguin-docs-font)',
377
+ };
378
+
379
+ if (!open) return null;
380
+
381
+ return (
382
+ <>
383
+ {mode === 'modal' && (
384
+ <div
385
+ onClick={onClose}
386
+ style={{
387
+ position: 'fixed',
388
+ inset: 0,
389
+ background: `rgba(0, 0, 0, ${parseFloat(String(0.5))})`,
390
+ zIndex: 9998,
391
+ }}
392
+ aria-hidden="true"
393
+ />
394
+ )}
395
+ <div id="cyguin-docs-widget" role="dialog" aria-modal={mode === 'modal'} aria-label="Documentation" style={panelStyle}>
396
+ <div style={{ display: 'flex', alignItems: 'center', padding: '12px 12px 8px', borderBottom: '1px solid var(--cyguin-docs-border)' }}>
397
+ <span style={{ flex: 1, fontWeight: 700, fontSize: '15px', color: 'var(--cyguin-docs-text)', fontFamily: 'var(--cyguin-docs-font)' }}>Help Center</span>
398
+ <button
399
+ onClick={onClose}
400
+ aria-label="Close"
401
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--cyguin-docs-muted)', display: 'flex', alignItems: 'center', padding: '4px', borderRadius: 'var(--cyguin-docs-radius)' }}
402
+ >
403
+ <CloseIcon />
404
+ </button>
405
+ </div>
406
+ <DocsSearch value={searchQuery} onChange={onSearchChange} />
407
+ <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
408
+ <div style={{ width: isMobile ? '100%' : '180px', display: 'flex', flexDirection: 'column' }}>
409
+ <DocsNav
410
+ groups={groups}
411
+ selectedId={selectedId}
412
+ selectedIndex={selectedIndex}
413
+ onSelectArticle={onSelectArticle}
414
+ searchQuery={searchQuery}
415
+ />
416
+ </div>
417
+ {!isMobile && <DocsArticleView article={article} onBack={onBack} />}
418
+ {isMobile && article && <DocsArticleView article={article} onBack={onBack} />}
419
+ </div>
420
+ </div>
421
+ </>
422
+ );
423
+ }
424
+
425
+ export function DocsWidget({
426
+ apiUrl = '/api/docs',
427
+ mode = 'modal',
428
+ triggerLabel = 'Help',
429
+ defaultOpen = false,
430
+ className = '',
431
+ }: DocsWidgetProps) {
432
+ const [open, setOpen] = useState(defaultOpen);
433
+ const [articles, setArticles] = useState<DocArticle[]>([]);
434
+ const [loading, setLoading] = useState(false);
435
+ const [searchQuery, setSearchQuery] = useState('');
436
+ const [selectedId, setSelectedId] = useState<string | null>(null);
437
+ const [selectedIndex, setSelectedIndex] = useState(0);
438
+ const [selectedArticle, setSelectedArticle] = useState<DocArticle | null>(null);
439
+
440
+ useEffect(() => {
441
+ if (open && articles.length === 0) {
442
+ setLoading(true);
443
+ fetch(apiUrl)
444
+ .then((r) => r.json())
445
+ .then((data) => {
446
+ const arts: DocArticle[] = Array.isArray(data) ? data : data.articles ?? data.data ?? [];
447
+ setArticles(arts);
448
+ })
449
+ .catch(() => setArticles([]))
450
+ .finally(() => setLoading(false));
451
+ }
452
+ }, [open, apiUrl, articles.length]);
453
+
454
+ useEffect(() => {
455
+ if (selectedId) {
456
+ const art = articles.find((a) => a.id === selectedId);
457
+ setSelectedArticle(art ?? null);
458
+ } else {
459
+ setSelectedArticle(null);
460
+ }
461
+ }, [selectedId, articles]);
462
+
463
+ useEffect(() => {
464
+ if (!open) return;
465
+ const handler = (e: KeyboardEvent) => {
466
+ if (e.key === 'Escape') setOpen(false);
467
+ };
468
+ document.addEventListener('keydown', handler);
469
+ return () => document.removeEventListener('keydown', handler);
470
+ }, [open]);
471
+
472
+ const handleSearchChange = useCallback((q: string) => {
473
+ setSearchQuery(q);
474
+ setSelectedId(null);
475
+ setSelectedIndex(0);
476
+ setSelectedArticle(null);
477
+ }, []);
478
+
479
+ const handleSelectArticle = useCallback((id: string, index: number) => {
480
+ setSelectedId(id);
481
+ setSelectedIndex(index);
482
+ const art = articles.find((a) => a.id === id);
483
+ setSelectedArticle(art ?? null);
484
+ }, [articles]);
485
+
486
+ const handleBack = useCallback(() => {
487
+ setSelectedId(null);
488
+ setSelectedArticle(null);
489
+ }, []);
490
+
491
+ const filteredArticles = searchQuery.trim()
492
+ ? articles.filter(
493
+ (a) =>
494
+ a.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
495
+ a.body_md.toLowerCase().includes(searchQuery.toLowerCase()),
496
+ )
497
+ : articles;
498
+
499
+ const groups = groupBySection(filteredArticles);
500
+
501
+ return (
502
+ <>
503
+ <div className={className} style={{ fontFamily: 'var(--cyguin-docs-font)' }}>
504
+ <DocsTrigger label={triggerLabel} onClick={() => setOpen(true)} />
505
+ <DocsPanel
506
+ mode={mode}
507
+ open={open}
508
+ onClose={() => setOpen(false)}
509
+ groups={groups}
510
+ selectedId={selectedId}
511
+ selectedIndex={selectedIndex}
512
+ searchQuery={searchQuery}
513
+ onSearchChange={handleSearchChange}
514
+ onSelectArticle={handleSelectArticle}
515
+ onBack={handleBack}
516
+ article={selectedArticle}
517
+ />
518
+ </div>
519
+ </>
520
+ );
521
+ }
@@ -0,0 +1,2 @@
1
+ export { DocsWidget } from './DocsWidget';
2
+ export type { DocsWidgetProps } from '../types.js';
@@ -0,0 +1,89 @@
1
+ import type { DocsAdapter, DocsHandlerOptions, NextHandler } from '../types'
2
+
3
+ let _adapter: DocsAdapter | null = null
4
+ let _secret: string | undefined = undefined
5
+
6
+ export function setAdminAdapter(adapter: DocsAdapter) {
7
+ _adapter = adapter
8
+ }
9
+
10
+ export function setAdminSecret(secret: string) {
11
+ _secret = secret
12
+ }
13
+
14
+ export function getAdminAdapter(): DocsAdapter {
15
+ if (!_adapter) {
16
+ throw new Error('Admin adapter not set. Call setAdminAdapter() first.')
17
+ }
18
+ return _adapter
19
+ }
20
+
21
+ export function createAdminHandler(options?: DocsHandlerOptions) {
22
+ const adapter = options?.adapter ?? getAdminAdapter()
23
+ const secret = options?.secret ?? _secret
24
+
25
+ return async function handler(
26
+ req: Request,
27
+ context?: { params?: Record<string, string | string[]> }
28
+ ): Promise<Response> {
29
+ if (secret) {
30
+ const authHeader = req.headers.get('Authorization')
31
+ const expected = `Bearer ${secret}`
32
+ if (authHeader !== expected) {
33
+ return Response.json({ error: 'Unauthorized' }, { status: 401 })
34
+ }
35
+ }
36
+
37
+ const pathParts = (context?.params?.route ?? []) as string[]
38
+ const method = req.method
39
+
40
+ try {
41
+ if (pathParts.length === 0 && method === 'POST') {
42
+ const body = await req.json()
43
+ const article = await adapter.create({
44
+ title: body.title,
45
+ body_md: body.body_md,
46
+ section: body.section,
47
+ article_order: body.article_order,
48
+ published_at: body.published_at ?? null,
49
+ })
50
+ return Response.json(article, { status: 201 })
51
+ }
52
+
53
+ if (pathParts.length === 1 && method === 'PUT') {
54
+ const [id] = pathParts
55
+ const body = await req.json()
56
+ const article = await adapter.update(id, body)
57
+ return Response.json(article)
58
+ }
59
+
60
+ if (pathParts.length === 2) {
61
+ const [id, action] = pathParts
62
+
63
+ if (action === 'order' && method === 'PATCH') {
64
+ const body = await req.json()
65
+ await adapter.reorder(id, body.article_order)
66
+ return Response.json({ success: true })
67
+ }
68
+
69
+ if (action === 'section' && method === 'PATCH') {
70
+ const body = await req.json()
71
+ await adapter.moveSection(id, body.section)
72
+ return Response.json({ success: true })
73
+ }
74
+ }
75
+
76
+ if (pathParts.length === 1 && method === 'DELETE') {
77
+ const [id] = pathParts
78
+ await adapter.delete(id)
79
+ return Response.json({ success: true })
80
+ }
81
+
82
+ return Response.json({ error: 'Not found' }, { status: 404 })
83
+ } catch (err) {
84
+ console.error('Admin handler error:', err)
85
+ const message = err instanceof Error ? err.message : 'Internal server error'
86
+ return Response.json({ error: message }, { status: 500 })
87
+ }
88
+ } as NextHandler
89
+ }
@@ -0,0 +1,70 @@
1
+ import type { DocsAdapter } from '../types'
2
+
3
+ let _adapter: DocsAdapter | null = null
4
+
5
+ export function setDocsAdapter(adapter: DocsAdapter) {
6
+ _adapter = adapter
7
+ }
8
+
9
+ export function getDocsAdapter(): DocsAdapter {
10
+ if (!_adapter) {
11
+ throw new Error('Docs adapter not set. Call setDocsAdapter() first.')
12
+ }
13
+ return _adapter
14
+ }
15
+
16
+ function slugify(title: string): string {
17
+ return title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
18
+ }
19
+
20
+ function kebabToTitle(kebab: string): string {
21
+ return kebab.replace(/-/g, ' ')
22
+ }
23
+
24
+ export function createDocsHandler(options: { adapter?: DocsAdapter }) {
25
+ const adapter = options?.adapter ?? getDocsAdapter()
26
+
27
+ return async function handler(
28
+ req: Request,
29
+ context?: { params?: Record<string, string | string[]> }
30
+ ): Promise<Response> {
31
+ const segments = (context?.params?.cyguin ?? []) as string[]
32
+
33
+ if (req.method !== 'GET') {
34
+ return Response.json({ error: 'Method not allowed' }, { status: 405 })
35
+ }
36
+
37
+ try {
38
+ if (segments.length === 0) {
39
+ const articles = await adapter.list({ published: true })
40
+ return Response.json(articles)
41
+ }
42
+
43
+ if (segments.length === 1) {
44
+ const [section] = segments
45
+ const articles = await adapter.list({ section, published: true })
46
+ return Response.json(articles)
47
+ }
48
+
49
+ if (segments.length === 2) {
50
+ const [section, slug] = segments
51
+ const titleQuery = kebabToTitle(slug)
52
+ const articles = await adapter.list({ section, published: true })
53
+ const article = articles.find(
54
+ (a) => slugify(a.title) === slug || a.title.toLowerCase() === titleQuery.toLowerCase()
55
+ )
56
+
57
+ if (!article) {
58
+ return Response.json({ error: 'Article not found' }, { status: 404 })
59
+ }
60
+
61
+ return Response.json(article)
62
+ }
63
+
64
+ return Response.json({ error: 'Not found' }, { status: 404 })
65
+ } catch (err) {
66
+ console.error('Docs handler error:', err)
67
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
68
+ }
69
+ }
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export type { DocArticle, DocsWidgetProps } from './types.js';
2
+
3
+ export { DocsWidget } from './components/index.js';
4
+
5
+ export { defaultCssVars } from './types.js';
package/src/next.ts ADDED
@@ -0,0 +1,3 @@
1
+ export type { DocsAdapter, DocArticle } from './types.js';
2
+
3
+ export { createDocsHandler } from './handlers/route.js';