specra 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.
Files changed (142) hide show
  1. package/LICENSE.MD +21 -0
  2. package/README.md +157 -0
  3. package/dist/app/api/mdx-watch/route.d.mts +12 -0
  4. package/dist/app/api/mdx-watch/route.d.ts +12 -0
  5. package/dist/app/api/mdx-watch/route.js +98 -0
  6. package/dist/app/api/mdx-watch/route.js.map +1 -0
  7. package/dist/app/api/mdx-watch/route.mjs +71 -0
  8. package/dist/app/api/mdx-watch/route.mjs.map +1 -0
  9. package/dist/app/docs-page.d.mts +32 -0
  10. package/dist/app/docs-page.d.ts +32 -0
  11. package/dist/app/docs-page.js +4072 -0
  12. package/dist/app/docs-page.js.map +1 -0
  13. package/dist/app/docs-page.mjs +14 -0
  14. package/dist/app/docs-page.mjs.map +1 -0
  15. package/dist/app/layout.css +297 -0
  16. package/dist/app/layout.css.map +1 -0
  17. package/dist/app/layout.d.mts +19 -0
  18. package/dist/app/layout.d.ts +19 -0
  19. package/dist/app/layout.js +112 -0
  20. package/dist/app/layout.js.map +1 -0
  21. package/dist/app/layout.mjs +13 -0
  22. package/dist/app/layout.mjs.map +1 -0
  23. package/dist/chunk-DR4EPLMT.mjs +1013 -0
  24. package/dist/chunk-DR4EPLMT.mjs.map +1 -0
  25. package/dist/chunk-INL2EC72.mjs +170 -0
  26. package/dist/chunk-INL2EC72.mjs.map +1 -0
  27. package/dist/chunk-IZFGEAD6.mjs +61 -0
  28. package/dist/chunk-IZFGEAD6.mjs.map +1 -0
  29. package/dist/chunk-KTRWWAGL.mjs +50 -0
  30. package/dist/chunk-KTRWWAGL.mjs.map +1 -0
  31. package/dist/chunk-MZJHJ6BV.mjs +21 -0
  32. package/dist/chunk-MZJHJ6BV.mjs.map +1 -0
  33. package/dist/chunk-NXRIAL7T.mjs +3119 -0
  34. package/dist/chunk-NXRIAL7T.mjs.map +1 -0
  35. package/dist/components/index.d.mts +822 -0
  36. package/dist/components/index.d.ts +822 -0
  37. package/dist/components/index.js +3738 -0
  38. package/dist/components/index.js.map +1 -0
  39. package/dist/components/index.mjs +3627 -0
  40. package/dist/components/index.mjs.map +1 -0
  41. package/dist/index.css +297 -0
  42. package/dist/index.css.map +1 -0
  43. package/dist/index.d.mts +545 -0
  44. package/dist/index.d.ts +545 -0
  45. package/dist/index.js +4648 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/index.mjs +347 -0
  48. package/dist/index.mjs.map +1 -0
  49. package/dist/lib/index.d.mts +798 -0
  50. package/dist/lib/index.d.ts +798 -0
  51. package/dist/lib/index.js +1301 -0
  52. package/dist/lib/index.js.map +1 -0
  53. package/dist/lib/index.mjs +89 -0
  54. package/dist/lib/index.mjs.map +1 -0
  55. package/package.json +119 -0
  56. package/src/app/api/mdx-watch/route.ts +86 -0
  57. package/src/app/docs-page.tsx +212 -0
  58. package/src/app/layout.tsx +74 -0
  59. package/src/components/docs/accordion.tsx +53 -0
  60. package/src/components/docs/api/api-endpoint.tsx +59 -0
  61. package/src/components/docs/api/api-params.tsx +43 -0
  62. package/src/components/docs/api/api-playground.tsx +233 -0
  63. package/src/components/docs/api/api-reference.tsx +291 -0
  64. package/src/components/docs/api/api-response.tsx +48 -0
  65. package/src/components/docs/api/index.ts +5 -0
  66. package/src/components/docs/badge.tsx +22 -0
  67. package/src/components/docs/breadcrumb.tsx +51 -0
  68. package/src/components/docs/callout.tsx +109 -0
  69. package/src/components/docs/card.tsx +84 -0
  70. package/src/components/docs/category-index.tsx +112 -0
  71. package/src/components/docs/code-block.tsx +129 -0
  72. package/src/components/docs/columns.tsx +45 -0
  73. package/src/components/docs/componentTextProps.ts +85 -0
  74. package/src/components/docs/dev-mode-badge.tsx +35 -0
  75. package/src/components/docs/doc-layout-wrapper.tsx +54 -0
  76. package/src/components/docs/doc-layout.tsx +111 -0
  77. package/src/components/docs/doc-loading.tsx +15 -0
  78. package/src/components/docs/doc-metadata.tsx +55 -0
  79. package/src/components/docs/doc-navigation.tsx +62 -0
  80. package/src/components/docs/doc-tags.tsx +25 -0
  81. package/src/components/docs/draft-badge.tsx +10 -0
  82. package/src/components/docs/footer.tsx +47 -0
  83. package/src/components/docs/frame.tsx +22 -0
  84. package/src/components/docs/header.tsx +122 -0
  85. package/src/components/docs/hot-reload-indicator.tsx +77 -0
  86. package/src/components/docs/icon.tsx +70 -0
  87. package/src/components/docs/image-card.tsx +95 -0
  88. package/src/components/docs/image.tsx +73 -0
  89. package/src/components/docs/index.ts +48 -0
  90. package/src/components/docs/math.tsx +46 -0
  91. package/src/components/docs/mdx-components.tsx +166 -0
  92. package/src/components/docs/mdx-hot-reload.tsx +37 -0
  93. package/src/components/docs/mermaid.tsx +77 -0
  94. package/src/components/docs/mobile-doc-layout.tsx +115 -0
  95. package/src/components/docs/not-found-content.tsx +55 -0
  96. package/src/components/docs/search-highlight.tsx +127 -0
  97. package/src/components/docs/search-modal.tsx +223 -0
  98. package/src/components/docs/sidebar-skeleton.tsx +39 -0
  99. package/src/components/docs/sidebar.tsx +323 -0
  100. package/src/components/docs/site-banner.tsx +92 -0
  101. package/src/components/docs/steps.tsx +29 -0
  102. package/src/components/docs/tab-context.tsx +28 -0
  103. package/src/components/docs/tab-groups.tsx +50 -0
  104. package/src/components/docs/table-of-contents.tsx +104 -0
  105. package/src/components/docs/tabs.tsx +63 -0
  106. package/src/components/docs/theme-toggle.tsx +39 -0
  107. package/src/components/docs/tooltip.tsx +37 -0
  108. package/src/components/docs/version-switcher.tsx +52 -0
  109. package/src/components/docs/video.tsx +80 -0
  110. package/src/components/global/index.ts +3 -0
  111. package/src/components/global/version-not-found.tsx +26 -0
  112. package/src/components/index.ts +8 -0
  113. package/src/components/theme-provider.tsx +11 -0
  114. package/src/components/ui/badge.tsx +46 -0
  115. package/src/components/ui/button.tsx +60 -0
  116. package/src/components/ui/dialog.tsx +143 -0
  117. package/src/components/ui/index.ts +6 -0
  118. package/src/components/ui/input.tsx +21 -0
  119. package/src/components/ui/textarea.tsx +18 -0
  120. package/src/index.ts +41 -0
  121. package/src/lib/api-parser.types.ts +78 -0
  122. package/src/lib/api.types.ts +202 -0
  123. package/src/lib/category.ts +71 -0
  124. package/src/lib/config.server.ts +170 -0
  125. package/src/lib/config.ts +20 -0
  126. package/src/lib/config.types.ts +295 -0
  127. package/src/lib/dev-utils.ts +75 -0
  128. package/src/lib/index.ts +27 -0
  129. package/src/lib/mdx-cache.ts +200 -0
  130. package/src/lib/mdx.ts +402 -0
  131. package/src/lib/parsers/base-parser.ts +16 -0
  132. package/src/lib/parsers/index.ts +69 -0
  133. package/src/lib/parsers/openapi-parser.ts +251 -0
  134. package/src/lib/parsers/postman-parser.ts +301 -0
  135. package/src/lib/parsers/specra-parser.ts +24 -0
  136. package/src/lib/redirects.ts +40 -0
  137. package/src/lib/remark-code-meta.ts +23 -0
  138. package/src/lib/sidebar-utils.ts +188 -0
  139. package/src/lib/toc.ts +24 -0
  140. package/src/lib/utils.ts +36 -0
  141. package/src/specra.config.json +124 -0
  142. package/src/styles/globals.css +427 -0
@@ -0,0 +1,212 @@
1
+ import type { Metadata } from "next"
2
+ import { extractTableOfContents, getAdjacentDocs, isCategoryPage } from "../lib/mdx"
3
+ import { getCachedVersions, getCachedAllDocs, getCachedDocBySlug } from "../lib/mdx-cache"
4
+ import { DocLayout } from "../components/docs/doc-layout"
5
+ import { TableOfContents } from "../components/docs/table-of-contents"
6
+ import { Header } from "../components/docs/header"
7
+ import { DocLayoutWrapper } from "../components/docs/doc-layout-wrapper"
8
+ import { HotReloadIndicator } from "../components/docs/hot-reload-indicator"
9
+ import { DevModeBadge } from "../components/docs/dev-mode-badge"
10
+ import { MdxHotReload } from "../components/docs/mdx-hot-reload"
11
+ import { CategoryIndex } from "../components/docs/category-index"
12
+ import { NotFoundContent } from "../components/docs/not-found-content"
13
+ import { getConfig } from "../lib/config"
14
+ import { Suspense } from "react"
15
+ import { DocLoading } from "../components/docs/doc-loading"
16
+
17
+ interface PageProps {
18
+ params: Promise<{
19
+ version: string
20
+ slug: string[]
21
+ }>
22
+ }
23
+
24
+ /**
25
+ * Generate metadata for documentation pages
26
+ * This is exported so users can re-export it from their page.tsx
27
+ */
28
+ export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
29
+ const { version, slug: slugArray } = await params
30
+ const slug = slugArray.join("/")
31
+
32
+ const doc = await getCachedDocBySlug(slug, version)
33
+
34
+ if (!doc) {
35
+ return {
36
+ title: "Page Not Found",
37
+ description: "The requested documentation page could not be found.",
38
+ }
39
+ }
40
+
41
+ const title = doc.meta.title || doc.title
42
+ const description = doc.meta.description || `Documentation for ${title}`
43
+ const url = `/docs/${version}/${slug}`
44
+
45
+ return {
46
+ title: `${title}`,
47
+ description,
48
+ openGraph: {
49
+ title,
50
+ description,
51
+ url,
52
+ siteName: "Documentation Platform",
53
+ type: "article",
54
+ locale: "en_US",
55
+ },
56
+ twitter: {
57
+ card: "summary_large_image",
58
+ title,
59
+ description,
60
+ },
61
+ alternates: {
62
+ canonical: url,
63
+ },
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Generate static params for all documentation pages
69
+ * This enables static generation at build time
70
+ */
71
+ export async function generateStaticParams() {
72
+ const versions = getCachedVersions()
73
+ const params = []
74
+
75
+ for (const version of versions) {
76
+ const docs = await getCachedAllDocs(version)
77
+ for (const doc of docs) {
78
+ // Add the custom slug path
79
+ params.push({
80
+ version,
81
+ slug: doc.slug.split("/").filter(Boolean),
82
+ })
83
+ }
84
+ }
85
+
86
+ return params
87
+ }
88
+
89
+ /**
90
+ * Documentation page component
91
+ * Handles:
92
+ * - Regular documentation pages
93
+ * - Category index pages (with or without content)
94
+ * - 404 pages (when doc not found)
95
+ */
96
+ export default async function DocPage({ params }: PageProps) {
97
+ const { version, slug: slugArray } = await params
98
+ const slug = slugArray.join("/")
99
+
100
+ const allDocs = await getCachedAllDocs(version)
101
+ const versions = getCachedVersions()
102
+ const config = getConfig()
103
+ const isCategory = isCategoryPage(slug, allDocs)
104
+
105
+ // Try to get the doc (might be index.mdx or regular .mdx)
106
+ const doc = await getCachedDocBySlug(slug, version)
107
+
108
+ // If no doc found and it's a category, show category index
109
+ if (!doc && isCategory) {
110
+ // Find a doc in this category to get the tab group
111
+ const categoryDoc = allDocs.find((d) => d.slug.startsWith(slug + "/"))
112
+ const categoryTabGroup = categoryDoc?.meta?.tab_group || categoryDoc?.categoryTabGroup
113
+
114
+ return (
115
+ <>
116
+ <DocLayoutWrapper
117
+ header={<Header currentVersion={version} versions={versions} config={config} />}
118
+ docs={allDocs}
119
+ version={version}
120
+ content={
121
+ <CategoryIndex
122
+ categoryPath={slug}
123
+ version={version}
124
+ allDocs={allDocs}
125
+ title={slug.split("/").pop()?.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()) || "Category"}
126
+ description="Browse the documentation in this section."
127
+ config={config}
128
+ />
129
+ }
130
+ toc={<div />}
131
+ config={config}
132
+ currentPageTabGroup={categoryTabGroup}
133
+ />
134
+ <MdxHotReload />
135
+ <HotReloadIndicator />
136
+ <DevModeBadge />
137
+ </>
138
+ )
139
+ }
140
+
141
+ // If no doc found, render 404 content within the layout (keeps sidebar visible)
142
+ if (!doc) {
143
+ return (
144
+ <>
145
+ <Suspense fallback={<DocLoading />}>
146
+ <DocLayoutWrapper
147
+ header={<Header currentVersion={version} versions={versions} config={config} />}
148
+ docs={allDocs}
149
+ version={version}
150
+ content={<NotFoundContent version={version} />}
151
+ toc={<div />}
152
+ config={config}
153
+ currentPageTabGroup={undefined}
154
+ />
155
+ <MdxHotReload />
156
+ <HotReloadIndicator />
157
+ <DevModeBadge />
158
+ </Suspense>
159
+ </>
160
+ )
161
+ }
162
+
163
+ const toc = extractTableOfContents(doc.content)
164
+ const { previous, next } = getAdjacentDocs(slug, allDocs)
165
+
166
+ // If doc exists but is also a category, show both content and children
167
+ const showCategoryIndex = isCategory && doc
168
+
169
+ // Get current page's tab group from doc metadata or category
170
+ const currentPageTabGroup = doc.meta?.tab_group || doc.categoryTabGroup
171
+
172
+ return (
173
+ <>
174
+ <Suspense fallback={<DocLoading />}>
175
+ <DocLayoutWrapper
176
+ header={<Header currentVersion={version} versions={versions} config={config} />}
177
+ docs={allDocs}
178
+ version={version}
179
+ content={
180
+ showCategoryIndex ? (
181
+ <CategoryIndex
182
+ categoryPath={slug}
183
+ version={version}
184
+ allDocs={allDocs}
185
+ title={doc.meta.title}
186
+ description={doc.meta.description}
187
+ content={doc.content}
188
+ config={config}
189
+ />
190
+ ) : (
191
+ <DocLayout
192
+ meta={doc.meta}
193
+ content={doc.content}
194
+ previousDoc={previous ? { title: previous.meta.title, slug: previous.slug } : undefined}
195
+ nextDoc={next ? { title: next.meta.title, slug: next.slug } : undefined}
196
+ version={version}
197
+ slug={slug}
198
+ config={config}
199
+ />
200
+ )
201
+ }
202
+ toc={showCategoryIndex ? <div /> : <TableOfContents items={toc} config={config} />}
203
+ config={config}
204
+ currentPageTabGroup={currentPageTabGroup}
205
+ />
206
+ <MdxHotReload />
207
+ <HotReloadIndicator />
208
+ <DevModeBadge />
209
+ </Suspense>
210
+ </>
211
+ )
212
+ }
@@ -0,0 +1,74 @@
1
+ import type React from "react"
2
+ import type { Metadata } from "next"
3
+ import { Geist, Geist_Mono } from "next/font/google"
4
+ import { getConfig } from "../lib/config"
5
+ import { getAssetPath } from "../lib/utils"
6
+ import { TabProvider } from "../components/docs/tab-context"
7
+ import "../styles/globals.css"
8
+
9
+ const geist = Geist({ subsets: ["latin"] })
10
+ const geistMono = Geist_Mono({ subsets: ["latin"] })
11
+
12
+ /**
13
+ * Generate metadata for the root layout
14
+ * This can be imported and used by the user's app/layout.tsx
15
+ */
16
+ export function generateMetadata(): Metadata {
17
+ const config = getConfig()
18
+
19
+ return {
20
+ title: {
21
+ default: config.site.title,
22
+ template: `%s | ${config.site.title}`,
23
+ },
24
+ description: config.site.description || "Modern documentation platform",
25
+ generator: "Specra Documentation",
26
+ metadataBase: config.site.url ? new URL(config.site.url) : undefined,
27
+ icons: {
28
+ icon: getAssetPath(config.site.favicon ?? "") ? [
29
+ {
30
+ url: getAssetPath(config.site.favicon ?? ""),
31
+ },
32
+ ] : [],
33
+ apple: getAssetPath("/apple-icon.png"),
34
+ },
35
+ openGraph: {
36
+ title: config.site.title,
37
+ description: config.site.description,
38
+ url: config.site.url,
39
+ siteName: config.site.title,
40
+ locale: config.site.language || "en",
41
+ type: "website",
42
+ },
43
+ twitter: {
44
+ card: "summary_large_image",
45
+ title: config.site.title,
46
+ description: config.site.description,
47
+ },
48
+ }
49
+ }
50
+
51
+ export const metadata: Metadata = generateMetadata()
52
+
53
+ /**
54
+ * Root layout component for Specra documentation sites
55
+ * This provides the HTML structure and global providers
56
+ */
57
+ export default function RootLayout({
58
+ children,
59
+ }: Readonly<{
60
+ children: React.ReactNode
61
+ }>) {
62
+ const config = getConfig()
63
+ const defaultTab = config.navigation?.tabGroups?.[0]?.id || ""
64
+
65
+ return (
66
+ <html lang={config.site.language || "en"} suppressHydrationWarning>
67
+ <body className={`${geist.className} font-sans antialiased`}>
68
+ <TabProvider defaultTab={defaultTab}>
69
+ {children}
70
+ </TabProvider>
71
+ </body>
72
+ </html>
73
+ )
74
+ }
@@ -0,0 +1,53 @@
1
+ "use client"
2
+
3
+ import { ChevronDown } from "lucide-react"
4
+ import { useState } from "react"
5
+
6
+ interface AccordionItemProps {
7
+ title: string | React.ReactNode
8
+ children: React.ReactNode
9
+ defaultOpen?: boolean
10
+ value?: string // For compatibility with radix-ui style API
11
+ }
12
+
13
+ export function AccordionItem({ title, children, defaultOpen = false }: AccordionItemProps) {
14
+ const [isOpen, setIsOpen] = useState(defaultOpen)
15
+
16
+ return (
17
+ <div className="border border-border rounded-xl overflow-hidden mb-2">
18
+ <button
19
+ onClick={() => setIsOpen(!isOpen)}
20
+ className="w-full flex items-center justify-between p-4 text-left bg-muted/30 hover:bg-muted/50 transition-colors"
21
+ >
22
+ <span className="font-medium text-foreground">{title}</span>
23
+ <ChevronDown
24
+ className={`h-5 w-5 text-muted-foreground transition-transform ${
25
+ isOpen ? "rotate-180" : ""
26
+ }`}
27
+ />
28
+ </button>
29
+ {isOpen && (
30
+ <div className="p-4 border-t border-border bg-background">
31
+ <div className="prose prose-sm dark:prose-invert max-w-none [&>*:last-child]:mb-0">
32
+ {children}
33
+ </div>
34
+ </div>
35
+ )}
36
+ </div>
37
+ )
38
+ }
39
+
40
+ interface AccordionProps {
41
+ children: React.ReactNode
42
+ type?: "single" | "multiple"
43
+ collapsible?: boolean // For compatibility with radix-ui style API
44
+ className?: string
45
+ }
46
+
47
+ export function Accordion({ children, type = "multiple", className }: AccordionProps) {
48
+ return (
49
+ <div className={className || "my-6 space-y-2"}>
50
+ {children}
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,59 @@
1
+ "use client"
2
+
3
+ import { type ReactNode, useState } from "react"
4
+ import { ChevronDown } from "lucide-react"
5
+ import { cn } from "@/lib/utils"
6
+
7
+ interface ApiEndpointProps {
8
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
9
+ path: string
10
+ summary?: string
11
+ children?: ReactNode
12
+ defaultOpen?: boolean
13
+ }
14
+
15
+ const methodColors = {
16
+ GET: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
17
+ POST: "bg-green-500/10 text-green-600 dark:text-green-400",
18
+ PUT: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
19
+ PATCH: "bg-purple-500/10 text-purple-600 dark:text-purple-400",
20
+ DELETE: "bg-red-500/10 text-red-600 dark:text-red-400",
21
+ }
22
+
23
+ export function ApiEndpoint({ method, path, summary, children, defaultOpen = false }: ApiEndpointProps) {
24
+ const [isOpen, setIsOpen] = useState(defaultOpen)
25
+
26
+ return (
27
+ <div className="not-prose mb-4 rounded-xl border border-border overflow-hidden">
28
+ {/* Accordion Header */}
29
+ <button
30
+ onClick={() => setIsOpen(!isOpen)}
31
+ className="w-full flex items-center gap-3 px-4 py-3 text-left bg-muted/30 hover:bg-muted/50 transition-colors"
32
+ >
33
+ <span
34
+ className={cn(
35
+ "text-xs font-semibold px-2 py-0.5 rounded",
36
+ methodColors[method]
37
+ )}
38
+ >
39
+ {method}
40
+ </span>
41
+ <code className="text-sm font-mono">{path}</code>
42
+ {summary && <span className="text-sm text-muted-foreground ml-auto mr-2">{summary}</span>}
43
+ <ChevronDown
44
+ className={cn(
45
+ "h-5 w-5 text-muted-foreground transition-transform flex-shrink-0",
46
+ isOpen ? "rotate-180" : ""
47
+ )}
48
+ />
49
+ </button>
50
+
51
+ {/* Accordion Content */}
52
+ {isOpen && children && (
53
+ <div className="border-t border-border bg-background">
54
+ <div className="px-4 py-4 space-y-6">{children}</div>
55
+ </div>
56
+ )}
57
+ </div>
58
+ )
59
+ }
@@ -0,0 +1,43 @@
1
+ interface ApiParam {
2
+ name: string
3
+ type: string
4
+ required?: boolean
5
+ description?: string
6
+ default?: string
7
+ }
8
+
9
+ interface ApiParamsProps {
10
+ title?: string
11
+ params: ApiParam[]
12
+ }
13
+
14
+ export function ApiParams({ title = "Parameters", params }: ApiParamsProps) {
15
+ if (!params || params.length === 0) return null
16
+
17
+ return (
18
+ <div className="mb-6">
19
+ <h4 className="text-sm font-semibold text-foreground mb-3">{title}</h4>
20
+ <div className="space-y-3">
21
+ {params.map((param) => (
22
+ <div key={param.name} className="flex flex-col gap-1">
23
+ <div className="flex items-center gap-2">
24
+ <code className="text-sm font-mono text-foreground">{param.name}</code>
25
+ <span className="text-xs text-muted-foreground">{param.type}</span>
26
+ {param.required && (
27
+ <span className="text-xs text-red-600 dark:text-red-400">required</span>
28
+ )}
29
+ {param.default && (
30
+ <span className="text-xs text-muted-foreground">
31
+ default: <code className="text-xs">{param.default}</code>
32
+ </span>
33
+ )}
34
+ </div>
35
+ {param.description && (
36
+ <p className="text-sm text-muted-foreground">{param.description}</p>
37
+ )}
38
+ </div>
39
+ ))}
40
+ </div>
41
+ </div>
42
+ )
43
+ }
@@ -0,0 +1,233 @@
1
+ "use client"
2
+
3
+ import { useState, useMemo } from "react"
4
+ import { Button } from "@/components/ui/button"
5
+ import { Input } from "@/components/ui/input"
6
+ import { Textarea } from "@/components/ui/textarea"
7
+ import { Badge } from "@/components/ui/badge"
8
+ import { CodeBlock } from "../code-block"
9
+ import { Play, Loader2 } from "lucide-react"
10
+
11
+ interface PathParam {
12
+ name: string
13
+ type: string
14
+ example?: any
15
+ }
16
+
17
+ interface ApiPlaygroundProps {
18
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
19
+ path: string
20
+ baseUrl?: string
21
+ headers?: Record<string, string>
22
+ defaultBody?: string
23
+ pathParams?: PathParam[]
24
+ }
25
+
26
+ export function ApiPlayground({
27
+ method,
28
+ path,
29
+ baseUrl = "",
30
+ headers = {},
31
+ defaultBody,
32
+ pathParams = []
33
+ }: ApiPlaygroundProps) {
34
+ const [loading, setLoading] = useState(false)
35
+ const [response, setResponse] = useState<any>(null)
36
+ const [error, setError] = useState<string | null>(null)
37
+ const [requestBody, setRequestBody] = useState(defaultBody || "")
38
+
39
+ // Initialize headers with empty strings if not provided
40
+ const initialHeaders = useMemo(() => {
41
+ const cleanHeaders: Record<string, string> = {}
42
+ Object.entries(headers).forEach(([key, value]) => {
43
+ cleanHeaders[key] = value || ""
44
+ })
45
+ return cleanHeaders
46
+ }, [headers])
47
+
48
+ const [requestHeaders, setRequestHeaders] = useState(JSON.stringify(initialHeaders, null, 2))
49
+
50
+ // Extract path parameters and initialize with defaults
51
+ const extractedParams = useMemo(() => {
52
+ const params: Record<string, string> = {}
53
+ const pathParamPattern = /:(\w+)/g
54
+ let match
55
+
56
+ while ((match = pathParamPattern.exec(path)) !== null) {
57
+ const paramName = match[1]
58
+ const paramConfig = pathParams.find(p => p.name === paramName)
59
+
60
+ // Set default value based on example or type
61
+ if (paramConfig?.example !== undefined) {
62
+ params[paramName] = String(paramConfig.example)
63
+ } else if (paramConfig?.type === "number") {
64
+ params[paramName] = "1"
65
+ } else {
66
+ params[paramName] = ""
67
+ }
68
+ }
69
+
70
+ return params
71
+ }, [path, pathParams])
72
+
73
+ const [pathParamValues, setPathParamValues] = useState<Record<string, string>>(extractedParams)
74
+
75
+ // Build the final URL with path params replaced
76
+ const buildUrl = () => {
77
+ let finalPath = path
78
+ Object.entries(pathParamValues).forEach(([key, value]) => {
79
+ finalPath = finalPath.replace(`:${key}`, value)
80
+ })
81
+ return `${baseUrl}${finalPath}`
82
+ }
83
+
84
+ const handleSend = async () => {
85
+ setLoading(true)
86
+ setError(null)
87
+ setResponse(null)
88
+
89
+ try {
90
+ const url = buildUrl()
91
+ const parsedHeaders = JSON.parse(requestHeaders)
92
+
93
+ const options: RequestInit = {
94
+ method,
95
+ headers: {
96
+ "Content-Type": "application/json",
97
+ ...parsedHeaders,
98
+ },
99
+ }
100
+
101
+ if (method !== "GET" && method !== "DELETE" && requestBody) {
102
+ options.body = requestBody
103
+ }
104
+
105
+ const res = await fetch(url, options)
106
+ const data = await res.json()
107
+
108
+ setResponse({
109
+ status: res.status,
110
+ statusText: res.statusText,
111
+ headers: Object.fromEntries(res.headers.entries()),
112
+ body: data,
113
+ })
114
+ } catch (err) {
115
+ setError(err instanceof Error ? err.message : "An error occurred")
116
+ } finally {
117
+ setLoading(false)
118
+ }
119
+ }
120
+
121
+ return (
122
+ <div className="not-prose border border-border rounded-lg overflow-hidden bg-card/30">
123
+ <div className="bg-muted/50 px-4 py-2 border-b border-border">
124
+ <h4 className="text-sm font-semibold text-foreground">API Playground</h4>
125
+ </div>
126
+
127
+ <div className="p-4 space-y-4">
128
+ {/* Path Parameters */}
129
+ {Object.keys(pathParamValues).length > 0 && (
130
+ <div>
131
+ <label className="text-xs font-semibold text-muted-foreground mb-2 block">
132
+ Path Parameters
133
+ </label>
134
+ <div className="space-y-2">
135
+ {Object.entries(pathParamValues).map(([paramName, paramValue]) => {
136
+ const paramConfig = pathParams.find(p => p.name === paramName)
137
+ return (
138
+ <div key={paramName} className="flex items-center gap-2">
139
+ <span className="text-xs text-muted-foreground min-w-[80px]">
140
+ :{paramName}
141
+ </span>
142
+ <Input
143
+ value={paramValue}
144
+ onChange={(e) =>
145
+ setPathParamValues((prev) => ({ ...prev, [paramName]: e.target.value }))
146
+ }
147
+ placeholder={paramConfig?.example || paramConfig?.type || "value"}
148
+ className="font-mono text-sm"
149
+ />
150
+ </div>
151
+ )
152
+ })}
153
+ </div>
154
+ </div>
155
+ )}
156
+
157
+ {/* URL */}
158
+ <div>
159
+ <label className="text-xs font-semibold text-muted-foreground mb-2 block">
160
+ Request URL
161
+ </label>
162
+ <div className="flex items-center gap-2">
163
+ <Badge variant="outline" className="font-mono">
164
+ {method}
165
+ </Badge>
166
+ <Input value={buildUrl()} readOnly className="font-mono text-sm" />
167
+ </div>
168
+ </div>
169
+
170
+ {/* Headers */}
171
+ <div>
172
+ <label className="text-xs font-semibold text-muted-foreground mb-2 block">
173
+ Headers (JSON)
174
+ </label>
175
+ <Textarea
176
+ value={requestHeaders}
177
+ onChange={(e) => setRequestHeaders(e.target.value)}
178
+ className="font-mono text-sm"
179
+ rows={4}
180
+ />
181
+ </div>
182
+
183
+ {/* Body (for POST, PUT, PATCH) */}
184
+ {method !== "GET" && method !== "DELETE" && (
185
+ <div>
186
+ <label className="text-xs font-semibold text-muted-foreground mb-2 block">
187
+ Request Body (JSON)
188
+ </label>
189
+ <Textarea
190
+ value={requestBody}
191
+ onChange={(e) => setRequestBody(e.target.value)}
192
+ className="font-mono text-sm"
193
+ rows={6}
194
+ placeholder='{\n "key": "value"\n}'
195
+ />
196
+ </div>
197
+ )}
198
+
199
+ {/* Send Button */}
200
+ <Button onClick={handleSend} disabled={loading} className="w-full">
201
+ {loading ? (
202
+ <>
203
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
204
+ Sending...
205
+ </>
206
+ ) : (
207
+ <>
208
+ <Play className="mr-2 h-4 w-4" />
209
+ Send Request
210
+ </>
211
+ )}
212
+ </Button>
213
+
214
+ {/* Response */}
215
+ {response && (
216
+ <div className="mt-4">
217
+ <label className="text-xs font-semibold text-muted-foreground mb-2 block">
218
+ Response ({response.status} {response.statusText})
219
+ </label>
220
+ <CodeBlock code={JSON.stringify(response.body, null, 2)} language="json" />
221
+ </div>
222
+ )}
223
+
224
+ {/* Error */}
225
+ {error && (
226
+ <div className="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-md">
227
+ <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
228
+ </div>
229
+ )}
230
+ </div>
231
+ </div>
232
+ )
233
+ }