@odvi/create-dtt-framework 0.1.3 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +16 -13
- package/dist/commands/create.js.map +1 -1
- package/package.json +3 -2
- package/template/.env.example +106 -0
- package/template/components.json +22 -0
- package/template/docs/framework/01-overview.md +289 -0
- package/template/docs/framework/02-techstack.md +503 -0
- package/template/docs/framework/api-layer.md +681 -0
- package/template/docs/framework/clerk-authentication.md +649 -0
- package/template/docs/framework/cli-installation.md +564 -0
- package/template/docs/framework/deployment/ci-cd.md +907 -0
- package/template/docs/framework/deployment/digitalocean.md +991 -0
- package/template/docs/framework/deployment/domain-setup.md +972 -0
- package/template/docs/framework/deployment/environment-variables.md +862 -0
- package/template/docs/framework/deployment/monitoring.md +927 -0
- package/template/docs/framework/deployment/production-checklist.md +649 -0
- package/template/docs/framework/deployment/vercel.md +791 -0
- package/template/docs/framework/environment-variables.md +646 -0
- package/template/docs/framework/health-check-system.md +583 -0
- package/template/docs/framework/implementation.md +559 -0
- package/template/docs/framework/snowflake-integration.md +594 -0
- package/template/docs/framework/state-management.md +615 -0
- package/template/docs/framework/supabase-integration.md +582 -0
- package/template/docs/framework/testing-guide.md +544 -0
- package/template/docs/framework/what-did-i-miss.md +526 -0
- package/template/drizzle.config.ts +11 -0
- package/template/next.config.js +21 -0
- package/template/postcss.config.js +5 -0
- package/template/prettier.config.js +4 -0
- package/template/public/favicon.ico +0 -0
- package/template/src/app/(auth)/layout.tsx +4 -0
- package/template/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +10 -0
- package/template/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +10 -0
- package/template/src/app/(dashboard)/dashboard/page.tsx +8 -0
- package/template/src/app/(dashboard)/health/page.tsx +16 -0
- package/template/src/app/(dashboard)/layout.tsx +17 -0
- package/template/src/app/api/[[...route]]/route.ts +11 -0
- package/template/src/app/api/debug-files/route.ts +33 -0
- package/template/src/app/api/webhooks/clerk/route.ts +112 -0
- package/template/src/app/layout.tsx +28 -0
- package/template/src/app/page.tsx +12 -0
- package/template/src/app/providers.tsx +20 -0
- package/template/src/components/layouts/navbar.tsx +14 -0
- package/template/src/components/shared/loading-spinner.tsx +6 -0
- package/template/src/components/ui/badge.tsx +46 -0
- package/template/src/components/ui/button.tsx +62 -0
- package/template/src/components/ui/card.tsx +92 -0
- package/template/src/components/ui/collapsible.tsx +33 -0
- package/template/src/components/ui/scroll-area.tsx +58 -0
- package/template/src/components/ui/sheet.tsx +139 -0
- package/template/src/config/__tests__/env.test.ts +164 -0
- package/template/src/config/__tests__/site.test.ts +46 -0
- package/template/src/config/env.ts +36 -0
- package/template/src/config/site.ts +10 -0
- package/template/src/env.js +44 -0
- package/template/src/features/__tests__/health-check-config.test.ts +142 -0
- package/template/src/features/__tests__/health-check-types.test.ts +201 -0
- package/template/src/features/documentation/components/doc-sidebar.tsx +109 -0
- package/template/src/features/documentation/components/doc-viewer.tsx +70 -0
- package/template/src/features/documentation/index.tsx +92 -0
- package/template/src/features/documentation/utils/doc-loader.ts +177 -0
- package/template/src/features/health-check/components/health-dashboard.tsx +374 -0
- package/template/src/features/health-check/config.ts +71 -0
- package/template/src/features/health-check/index.ts +4 -0
- package/template/src/features/health-check/stores/health-store.ts +14 -0
- package/template/src/features/health-check/types.ts +18 -0
- package/template/src/hooks/__tests__/use-debounce.test.tsx +28 -0
- package/template/src/hooks/queries/use-health-checks.ts +16 -0
- package/template/src/hooks/utils/use-debounce.ts +20 -0
- package/template/src/lib/__tests__/utils.test.ts +52 -0
- package/template/src/lib/__tests__/validators.test.ts +114 -0
- package/template/src/lib/nextbank/client.ts +67 -0
- package/template/src/lib/snowflake/client.ts +102 -0
- package/template/src/lib/supabase/admin.ts +7 -0
- package/template/src/lib/supabase/client.ts +7 -0
- package/template/src/lib/supabase/server.ts +23 -0
- package/template/src/lib/utils.ts +6 -0
- package/template/src/lib/validators.ts +9 -0
- package/template/src/middleware.ts +22 -0
- package/template/src/server/api/index.ts +22 -0
- package/template/src/server/api/middleware/auth.ts +19 -0
- package/template/src/server/api/middleware/logger.ts +4 -0
- package/template/src/server/api/routes/health/clerk.ts +214 -0
- package/template/src/server/api/routes/health/database.ts +141 -0
- package/template/src/server/api/routes/health/edge-functions.ts +107 -0
- package/template/src/server/api/routes/health/framework.ts +48 -0
- package/template/src/server/api/routes/health/index.ts +102 -0
- package/template/src/server/api/routes/health/nextbank.ts +46 -0
- package/template/src/server/api/routes/health/snowflake.ts +83 -0
- package/template/src/server/api/routes/health/storage.ts +177 -0
- package/template/src/server/api/routes/users.ts +79 -0
- package/template/src/server/db/index.ts +17 -0
- package/template/src/server/db/queries/users.ts +8 -0
- package/template/src/server/db/schema/__tests__/health-checks.test.ts +31 -0
- package/template/src/server/db/schema/__tests__/users.test.ts +46 -0
- package/template/src/server/db/schema/health-checks.ts +11 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/users.ts +16 -0
- package/template/src/server/db/schema.ts +1 -0
- package/template/src/stores/__tests__/ui-store.test.ts +87 -0
- package/template/src/stores/ui-store.ts +14 -0
- package/template/src/styles/globals.css +129 -0
- package/template/src/test/mocks/clerk.ts +35 -0
- package/template/src/test/mocks/snowflake.ts +28 -0
- package/template/src/test/mocks/supabase.ts +37 -0
- package/template/src/test/setup.ts +69 -0
- package/template/src/test/utils/test-helpers.ts +158 -0
- package/template/src/types/index.ts +14 -0
- package/template/tsconfig.json +43 -0
- package/template/vitest.config.ts +44 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useSearchParams } from 'next/navigation';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
import { FileText, Folder, FolderOpen, ChevronRight, ChevronDown } from 'lucide-react';
|
|
8
|
+
import type { DocNode } from '../utils/doc-loader';
|
|
9
|
+
import { Button } from '@/components/ui/button';
|
|
10
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
11
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
12
|
+
|
|
13
|
+
interface DocSidebarProps {
|
|
14
|
+
nodes: DocNode[];
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function DocSidebar({ nodes, className }: DocSidebarProps) {
|
|
19
|
+
const searchParams = useSearchParams();
|
|
20
|
+
const currentDoc = searchParams.get('doc');
|
|
21
|
+
|
|
22
|
+
// If no doc is selected, we might want to highlight README or nothing
|
|
23
|
+
// But usually README corresponds to 'README.md' id if we clicked it.
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className={cn('pb-12 w-64 border-r h-full bg-muted/10', className)}>
|
|
27
|
+
<div className="space-y-4 py-4">
|
|
28
|
+
<div className="px-3 py-2">
|
|
29
|
+
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
|
30
|
+
Documentation
|
|
31
|
+
</h2>
|
|
32
|
+
<ScrollArea className="h-[calc(100vh-8rem)] px-1">
|
|
33
|
+
<div className="space-y-1 p-2">
|
|
34
|
+
{nodes.map((node) => (
|
|
35
|
+
<DocNodeItem key={node.id} node={node} currentDoc={currentDoc} />
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
</ScrollArea>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function DocNodeItem({ node, currentDoc }: { node: DocNode; currentDoc: string | null }) {
|
|
46
|
+
const [isOpen, setIsOpen] = React.useState(false);
|
|
47
|
+
|
|
48
|
+
// Auto-expand if a child is active
|
|
49
|
+
React.useEffect(() => {
|
|
50
|
+
if (node.type === 'directory' && node.children) {
|
|
51
|
+
const hasActiveChild = checkActiveChild(node, currentDoc);
|
|
52
|
+
if (hasActiveChild) {
|
|
53
|
+
setIsOpen(true);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}, [currentDoc, node]);
|
|
57
|
+
|
|
58
|
+
if (node.type === 'directory') {
|
|
59
|
+
return (
|
|
60
|
+
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="w-full">
|
|
61
|
+
<CollapsibleTrigger asChild>
|
|
62
|
+
<Button
|
|
63
|
+
variant="ghost"
|
|
64
|
+
size="sm"
|
|
65
|
+
className="w-full justify-start font-normal"
|
|
66
|
+
>
|
|
67
|
+
{isOpen ? (
|
|
68
|
+
<ChevronDown className="mr-2 h-4 w-4" />
|
|
69
|
+
) : (
|
|
70
|
+
<ChevronRight className="mr-2 h-4 w-4" />
|
|
71
|
+
)}
|
|
72
|
+
<Folder className="mr-2 h-4 w-4 text-sky-500" />
|
|
73
|
+
{node.title}
|
|
74
|
+
</Button>
|
|
75
|
+
</CollapsibleTrigger>
|
|
76
|
+
<CollapsibleContent className="pl-4 space-y-1">
|
|
77
|
+
{node.children?.map((child) => (
|
|
78
|
+
<DocNodeItem key={child.id} node={child} currentDoc={currentDoc} />
|
|
79
|
+
))}
|
|
80
|
+
</CollapsibleContent>
|
|
81
|
+
</Collapsible>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const isActive = currentDoc === node.id || (!currentDoc && node.id === 'README.md');
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<Button
|
|
89
|
+
asChild
|
|
90
|
+
variant={isActive ? 'secondary' : 'ghost'}
|
|
91
|
+
size="sm"
|
|
92
|
+
className={cn('w-full justify-start font-normal', isActive && 'bg-accent')}
|
|
93
|
+
>
|
|
94
|
+
<Link href={`/?doc=${encodeURIComponent(node.id)}`} scroll={false}>
|
|
95
|
+
<FileText className="mr-2 h-4 w-4 text-slate-500" />
|
|
96
|
+
{node.title}
|
|
97
|
+
</Link>
|
|
98
|
+
</Button>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkActiveChild(node: DocNode, currentDoc: string | null): boolean {
|
|
103
|
+
if (node.id === currentDoc) return true;
|
|
104
|
+
if (node.children) {
|
|
105
|
+
return node.children.some(child => checkActiveChild(child, currentDoc));
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactMarkdown from 'react-markdown';
|
|
3
|
+
import remarkGfm from 'remark-gfm';
|
|
4
|
+
import rehypeHighlight from 'rehype-highlight';
|
|
5
|
+
import 'highlight.js/styles/github-dark.css'; // or any other style
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
import type { DocContent } from '../utils/doc-loader';
|
|
8
|
+
|
|
9
|
+
interface DocViewerProps {
|
|
10
|
+
doc: DocContent;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function DocViewer({ doc, className }: DocViewerProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div className={cn("max-w-4xl mx-auto py-8 px-6", className)}>
|
|
17
|
+
<div className="mb-8 border-b pb-4">
|
|
18
|
+
<h1 className="text-4xl font-bold tracking-tight text-foreground">
|
|
19
|
+
{doc.title}
|
|
20
|
+
</h1>
|
|
21
|
+
{doc.frontmatter.description && (
|
|
22
|
+
<p className="mt-2 text-xl text-muted-foreground">
|
|
23
|
+
{doc.frontmatter.description}
|
|
24
|
+
</p>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<article className="prose prose-slate dark:prose-invert max-w-none
|
|
29
|
+
prose-headings:scroll-m-20
|
|
30
|
+
prose-h1:text-3xl prose-h1:font-extrabold prose-h1:tracking-tight prose-h1:mb-4
|
|
31
|
+
prose-h2:text-2xl prose-h2:font-semibold prose-h2:tracking-tight prose-h2:mt-10 prose-h2:mb-4
|
|
32
|
+
prose-h3:text-xl prose-h3:font-semibold prose-h3:tracking-tight prose-h3:mt-8 prose-h3:mb-4
|
|
33
|
+
prose-p:leading-7 prose-p:mb-4
|
|
34
|
+
prose-ul:my-6 prose-ul:list-disc prose-ul:pl-6
|
|
35
|
+
prose-code:relative prose-code:rounded prose-code:bg-muted prose-code:px-[0.3rem] prose-code:py-[0.2rem] prose-code:font-mono prose-code:text-sm prose-code:font-semibold
|
|
36
|
+
prose-pre:p-4 prose-pre:rounded-lg prose-pre:bg-muted/50 prose-pre:border
|
|
37
|
+
">
|
|
38
|
+
<ReactMarkdown
|
|
39
|
+
remarkPlugins={[remarkGfm]}
|
|
40
|
+
rehypePlugins={[rehypeHighlight]}
|
|
41
|
+
components={{
|
|
42
|
+
// Override components if needed for custom styling or functionality
|
|
43
|
+
a: ({node, ...props}) => {
|
|
44
|
+
// Check if link is local markdown file
|
|
45
|
+
const href = props.href || '';
|
|
46
|
+
if (href.startsWith('http')) {
|
|
47
|
+
return <a target="_blank" rel="noopener noreferrer" {...props} className="text-primary hover:underline font-medium" />
|
|
48
|
+
}
|
|
49
|
+
// Convert relative markdown links to ?doc= links
|
|
50
|
+
// This is a bit complex as we need to resolve relative paths against current doc ID
|
|
51
|
+
// For now, let's just leave them as is, or handle basic cases
|
|
52
|
+
return <a {...props} className="text-primary hover:underline font-medium" />
|
|
53
|
+
},
|
|
54
|
+
blockquote: ({node, ...props}) => (
|
|
55
|
+
<blockquote className="mt-6 border-l-2 border-primary pl-6 italic" {...props} />
|
|
56
|
+
),
|
|
57
|
+
table: ({node, ...props}) => (
|
|
58
|
+
<div className="my-6 w-full overflow-y-auto">
|
|
59
|
+
<table className="w-full" {...props} />
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
{doc.content}
|
|
65
|
+
</ReactMarkdown>
|
|
66
|
+
</article>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { getDocsStructure, getDocContent } from './utils/doc-loader';
|
|
3
|
+
import { DocSidebar } from './components/doc-sidebar';
|
|
4
|
+
import { DocViewer } from './components/doc-viewer';
|
|
5
|
+
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
7
|
+
import { Menu, Activity } from 'lucide-react';
|
|
8
|
+
import Link from 'next/link';
|
|
9
|
+
import { UserButton } from '@clerk/nextjs';
|
|
10
|
+
|
|
11
|
+
interface DocumentationLayoutProps {
|
|
12
|
+
currentDocId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function DocumentationFeature({ currentDocId }: DocumentationLayoutProps) {
|
|
16
|
+
const structure = await getDocsStructure();
|
|
17
|
+
|
|
18
|
+
// Default to README.md if no doc selected, or if selected doc not found
|
|
19
|
+
const targetId = currentDocId || 'README.md';
|
|
20
|
+
let doc = await getDocContent(targetId);
|
|
21
|
+
|
|
22
|
+
// Fallback if not found (e.g. invalid ID)
|
|
23
|
+
if (!doc && targetId !== 'README.md') {
|
|
24
|
+
doc = await getDocContent('README.md');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// If still no doc (shouldn't happen), show error
|
|
28
|
+
if (!doc) {
|
|
29
|
+
return <div>Documentation not found.</div>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex min-h-screen flex-col">
|
|
34
|
+
{/* Header */}
|
|
35
|
+
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
36
|
+
<div className="container mx-auto flex h-14 items-center justify-between">
|
|
37
|
+
<div className="flex items-center">
|
|
38
|
+
<Sheet>
|
|
39
|
+
<SheetTrigger asChild>
|
|
40
|
+
<Button variant="ghost" className="mr-2 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 lg:hidden">
|
|
41
|
+
<Menu className="h-6 w-6" />
|
|
42
|
+
<span className="sr-only">Toggle Menu</span>
|
|
43
|
+
</Button>
|
|
44
|
+
</SheetTrigger>
|
|
45
|
+
<SheetContent side="left" className="pr-0">
|
|
46
|
+
<div className="px-7">
|
|
47
|
+
<Link href="/" className="font-bold">
|
|
48
|
+
DTT Framework
|
|
49
|
+
</Link>
|
|
50
|
+
</div>
|
|
51
|
+
<DocSidebar nodes={structure} className="mt-4 border-none" />
|
|
52
|
+
</SheetContent>
|
|
53
|
+
</Sheet>
|
|
54
|
+
<div className="mr-4 hidden md:flex">
|
|
55
|
+
<Link href="/" className="mr-6 flex items-center space-x-2">
|
|
56
|
+
<span className="hidden font-bold sm:inline-block">
|
|
57
|
+
DTT Framework
|
|
58
|
+
</span>
|
|
59
|
+
</Link>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div className="flex items-center gap-4">
|
|
64
|
+
<Link href="/health">
|
|
65
|
+
<Button variant="outline" size="sm" className="gap-2">
|
|
66
|
+
<Activity className="h-4 w-4" />
|
|
67
|
+
Health Check
|
|
68
|
+
</Button>
|
|
69
|
+
</Link>
|
|
70
|
+
<UserButton afterSignOutUrl="/sign-in" />
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</header>
|
|
74
|
+
|
|
75
|
+
<div className="flex-1 items-start md:grid md:grid-cols-[240px_minmax(0,1fr)] lg:grid-cols-[280px_minmax(0,1fr)]">
|
|
76
|
+
{/* Desktop Sidebar */}
|
|
77
|
+
<aside className="fixed top-14 z-30 -ml-2 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 md:sticky md:block">
|
|
78
|
+
<div className="h-full py-6 pl-8 pr-6 lg:py-8">
|
|
79
|
+
<DocSidebar nodes={structure} className="h-full border-none bg-transparent" />
|
|
80
|
+
</div>
|
|
81
|
+
</aside>
|
|
82
|
+
|
|
83
|
+
{/* Main Content */}
|
|
84
|
+
<main className="relative py-6 lg:gap-10 lg:py-8 xl:grid xl:grid-cols-[1fr_300px]">
|
|
85
|
+
<div className="mx-auto w-full min-w-0">
|
|
86
|
+
<DocViewer doc={doc} />
|
|
87
|
+
</div>
|
|
88
|
+
</main>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
|
|
5
|
+
const ROOT_DIR = (() => {
|
|
6
|
+
let currentDir = process.cwd();
|
|
7
|
+
// In Vercel, process.cwd() is usually /var/task
|
|
8
|
+
// But we can try to find package.json to be sure
|
|
9
|
+
try {
|
|
10
|
+
// Check if we are already at root
|
|
11
|
+
if (fs.existsSync(path.join(currentDir, 'package.json'))) {
|
|
12
|
+
return currentDir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Look up a few levels
|
|
16
|
+
for (let i = 0; i < 3; i++) {
|
|
17
|
+
const parent = path.dirname(currentDir);
|
|
18
|
+
if (parent === currentDir) break;
|
|
19
|
+
if (fs.existsSync(path.join(parent, 'package.json'))) {
|
|
20
|
+
return parent;
|
|
21
|
+
}
|
|
22
|
+
currentDir = parent;
|
|
23
|
+
}
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.error('Error finding root dir:', e);
|
|
26
|
+
}
|
|
27
|
+
return process.cwd();
|
|
28
|
+
})();
|
|
29
|
+
|
|
30
|
+
const DOCS_DIR = path.join(ROOT_DIR, 'docs');
|
|
31
|
+
|
|
32
|
+
export interface DocNode {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
type: 'file' | 'directory';
|
|
36
|
+
children?: DocNode[];
|
|
37
|
+
path?: string; // real file path relative to root
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DocContent {
|
|
41
|
+
title: string;
|
|
42
|
+
content: string;
|
|
43
|
+
frontmatter: Record<string, any>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ALLOWED_ROOT_FILES = ['README.md', 'DESIGN.md'];
|
|
47
|
+
const ALLOWED_CLI_FILES = ['cli/template/README.md'];
|
|
48
|
+
|
|
49
|
+
// Helper to format title from filename
|
|
50
|
+
function formatTitle(filename: string): string {
|
|
51
|
+
return filename
|
|
52
|
+
.replace(/\.mdx?$/, '')
|
|
53
|
+
.replace(/^\d+-/, '') // Remove leading numbers like 01-
|
|
54
|
+
.replace(/-/g, ' ')
|
|
55
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getDocsStructure(): Promise<DocNode[]> {
|
|
59
|
+
console.log('[Debug] ROOT_DIR:', ROOT_DIR);
|
|
60
|
+
try {
|
|
61
|
+
if (fs.existsSync(ROOT_DIR)) {
|
|
62
|
+
const entries = await fs.promises.readdir(ROOT_DIR);
|
|
63
|
+
console.log('[Debug] Root entries:', entries);
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error('[Debug] Error listing root:', error);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const nodes: DocNode[] = [];
|
|
70
|
+
|
|
71
|
+
// 1. Root files
|
|
72
|
+
for (const filename of ALLOWED_ROOT_FILES) {
|
|
73
|
+
if (fs.existsSync(path.join(ROOT_DIR, filename))) {
|
|
74
|
+
nodes.push({
|
|
75
|
+
id: filename,
|
|
76
|
+
title: filename === 'README.md' ? 'Introduction' : formatTitle(filename),
|
|
77
|
+
type: 'file',
|
|
78
|
+
path: filename
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 2. CLI Docs
|
|
84
|
+
for (const filepath of ALLOWED_CLI_FILES) {
|
|
85
|
+
if (fs.existsSync(path.join(ROOT_DIR, filepath))) {
|
|
86
|
+
nodes.push({
|
|
87
|
+
id: filepath,
|
|
88
|
+
title: 'CLI Template',
|
|
89
|
+
type: 'file',
|
|
90
|
+
path: filepath
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. Docs folder
|
|
96
|
+
const docsNodes = await scanDirectory(DOCS_DIR, 'docs');
|
|
97
|
+
nodes.push(...docsNodes);
|
|
98
|
+
|
|
99
|
+
return nodes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function scanDirectory(dir: string, parentId: string): Promise<DocNode[]> {
|
|
103
|
+
if (!fs.existsSync(dir)) return [];
|
|
104
|
+
|
|
105
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
106
|
+
const nodes: DocNode[] = [];
|
|
107
|
+
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
const fullPath = path.join(dir, entry.name);
|
|
110
|
+
const relativePath = path.relative(ROOT_DIR, fullPath);
|
|
111
|
+
const id = relativePath.replace(/\\/g, '/'); // Normalize for URLs
|
|
112
|
+
|
|
113
|
+
if (entry.isDirectory()) {
|
|
114
|
+
const children = await scanDirectory(fullPath, id);
|
|
115
|
+
if (children.length > 0) {
|
|
116
|
+
nodes.push({
|
|
117
|
+
id,
|
|
118
|
+
title: formatTitle(entry.name),
|
|
119
|
+
type: 'directory',
|
|
120
|
+
children
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
} else if (entry.name.endsWith('.md') || entry.name.endsWith('.mdx')) {
|
|
124
|
+
// Read frontmatter for title if possible, else derive from filename
|
|
125
|
+
const fileContent = await fs.promises.readFile(fullPath, 'utf-8');
|
|
126
|
+
const { data } = matter(fileContent);
|
|
127
|
+
|
|
128
|
+
nodes.push({
|
|
129
|
+
id,
|
|
130
|
+
title: data.title || formatTitle(entry.name),
|
|
131
|
+
type: 'file',
|
|
132
|
+
path: relativePath
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Sort: Directories first, then files alphabetically (or by prefix if present)
|
|
138
|
+
return nodes.sort((a, b) => {
|
|
139
|
+
if (a.type === b.type) return a.id.localeCompare(b.id);
|
|
140
|
+
return a.type === 'directory' ? 1 : -1; // Keep directories at bottom usually? Or top?
|
|
141
|
+
// Usually folders like "framework" contain the files.
|
|
142
|
+
// Let's just sort by ID for now, which handles 01-overview vs 02-techstack
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function getDocContent(id: string): Promise<DocContent | null> {
|
|
147
|
+
// Security check: ensure id resolves to a file within allowed paths
|
|
148
|
+
const cleanId = id.replace(/\.\./g, ''); // Prevent directory traversal
|
|
149
|
+
const fullPath = path.join(ROOT_DIR, cleanId);
|
|
150
|
+
|
|
151
|
+
// Validate that the resolved path is within ROOT_DIR and is one of the allowed types/locations
|
|
152
|
+
if (!fullPath.startsWith(ROOT_DIR)) return null;
|
|
153
|
+
|
|
154
|
+
// Basic check: file must exist and be md/mdx
|
|
155
|
+
if (!fs.existsSync(fullPath)) return null;
|
|
156
|
+
if (!fullPath.endsWith('.md') && !fullPath.endsWith('.mdx')) return null;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const fileContent = await fs.promises.readFile(fullPath, 'utf-8');
|
|
160
|
+
const { data, content } = matter(fileContent);
|
|
161
|
+
|
|
162
|
+
let title = data.title;
|
|
163
|
+
if (!title) {
|
|
164
|
+
title = formatTitle(path.basename(fullPath));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
title,
|
|
169
|
+
content,
|
|
170
|
+
frontmatter: data,
|
|
171
|
+
};
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error(`Error reading doc ${id} at ${fullPath}:`, error);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|