@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,130 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { generators, getLanguageForHighlighter } from "./generators";
3
+
4
+ describe("generators", () => {
5
+ const url = "https://api.example.com/users/123";
6
+ const headers = {
7
+ "Content-Type": "application/json",
8
+ Authorization: "Bearer token123",
9
+ };
10
+ const body = '{"name": "John", "email": "john@example.com"}';
11
+
12
+ describe("curl", () => {
13
+ it("generates POST request with headers and body", () => {
14
+ const result = generators.curl("POST", url, headers, body);
15
+ expect(result).toContain("curl");
16
+ expect(result).toContain("-X POST");
17
+ expect(result).toContain(`'${url}'`);
18
+ expect(result).toContain("-H 'Content-Type: application/json'");
19
+ expect(result).toContain("-H 'Authorization: Bearer token123'");
20
+ expect(result).toContain("-d '{\"name\": \"John\", \"email\": \"john@example.com\"}'");
21
+ });
22
+
23
+ it("generates GET request without -X flag", () => {
24
+ const result = generators.curl("GET", url, headers);
25
+ expect(result).toContain("curl");
26
+ expect(result).not.toContain("-X GET");
27
+ expect(result).toContain(`'${url}'`);
28
+ });
29
+
30
+ it("generates simple GET as single line", () => {
31
+ const result = generators.curl("GET", url, {});
32
+ expect(result).toBe(`curl '${url}'`);
33
+ expect(result).not.toContain("\\");
34
+ });
35
+
36
+ it("omits body when not provided", () => {
37
+ const result = generators.curl("POST", url, headers);
38
+ expect(result).not.toContain("-d");
39
+ });
40
+
41
+ it("escapes single quotes in URL", () => {
42
+ const urlWithQuote = "https://api.example.com/users?name=O'Brien";
43
+ const result = generators.curl("GET", urlWithQuote, {});
44
+ expect(result).toContain("O'\\''Brien");
45
+ });
46
+
47
+ it("handles empty headers", () => {
48
+ const result = generators.curl("GET", url, {});
49
+ expect(result).not.toContain("-H");
50
+ });
51
+
52
+ it("generates multi-line for POST with body", () => {
53
+ const result = generators.curl("POST", url, {}, body);
54
+ expect(result).toContain("curl \\\n");
55
+ expect(result).toContain("-X POST");
56
+ expect(result).toContain("-d");
57
+ });
58
+ });
59
+
60
+ describe("javascript", () => {
61
+ it("generates fetch with headers and body", () => {
62
+ const result = generators.javascript("POST", url, headers, body);
63
+ expect(result).toContain("await fetch");
64
+ expect(result).toContain(`'${url}'`);
65
+ expect(result).toContain("method: 'POST'");
66
+ expect(result).toContain("headers:");
67
+ expect(result).toContain("'Content-Type': 'application/json'");
68
+ expect(result).toContain("body: JSON.stringify");
69
+ expect(result).toContain("await response.json()");
70
+ });
71
+
72
+ it("generates fetch without body for GET", () => {
73
+ const result = generators.javascript("GET", url, headers);
74
+ expect(result).toContain("method: 'GET'");
75
+ expect(result).not.toContain("body:");
76
+ });
77
+
78
+ it("omits headers when empty", () => {
79
+ const result = generators.javascript("GET", url, {});
80
+ expect(result).not.toContain("headers:");
81
+ });
82
+ });
83
+
84
+ describe("python", () => {
85
+ it("generates requests call with headers and body", () => {
86
+ const result = generators.python("POST", url, headers, body);
87
+ expect(result).toContain("import requests");
88
+ expect(result).toContain("headers = {");
89
+ expect(result).toContain("'Content-Type': 'application/json'");
90
+ expect(result).toContain("json_data =");
91
+ expect(result).toContain("requests.post(");
92
+ expect(result).toContain("headers=headers");
93
+ expect(result).toContain("json=json_data");
94
+ expect(result).toContain("print(response.json())");
95
+ });
96
+
97
+ it("uses correct method name", () => {
98
+ expect(generators.python("GET", url, {})).toContain("requests.get(");
99
+ expect(generators.python("PUT", url, {})).toContain("requests.put(");
100
+ expect(generators.python("DELETE", url, {})).toContain("requests.delete(");
101
+ expect(generators.python("PATCH", url, {})).toContain("requests.patch(");
102
+ });
103
+
104
+ it("omits headers when empty", () => {
105
+ const result = generators.python("GET", url, {});
106
+ expect(result).not.toContain("headers = {");
107
+ expect(result).not.toContain("headers=headers");
108
+ });
109
+
110
+ it("omits body when not provided", () => {
111
+ const result = generators.python("POST", url, headers);
112
+ expect(result).not.toContain("json_data");
113
+ expect(result).not.toContain("json=json_data");
114
+ });
115
+ });
116
+ });
117
+
118
+ describe("getLanguageForHighlighter", () => {
119
+ it("maps curl to bash", () => {
120
+ expect(getLanguageForHighlighter("curl")).toBe("bash");
121
+ });
122
+
123
+ it("maps javascript to javascript", () => {
124
+ expect(getLanguageForHighlighter("javascript")).toBe("javascript");
125
+ });
126
+
127
+ it("maps python to python", () => {
128
+ expect(getLanguageForHighlighter("python")).toBe("python");
129
+ });
130
+ });
@@ -0,0 +1,75 @@
1
+ type GeneratorFn = (
2
+ method: string,
3
+ url: string,
4
+ headers: Record<string, string>,
5
+ body?: string
6
+ ) => string;
7
+
8
+ const escapeShell = (s: string) => s.replace(/'/g, "'\\''");
9
+
10
+ export const generators: Record<"curl" | "javascript" | "python", GeneratorFn> = {
11
+ curl: (method, url, headers, body) => {
12
+ const parts: string[] = [];
13
+ if (method !== "GET") parts.push(`-X ${method}`);
14
+ parts.push(`'${escapeShell(url)}'`);
15
+ for (const [k, v] of Object.entries(headers)) {
16
+ parts.push(`-H '${escapeShell(k)}: ${escapeShell(v)}'`);
17
+ }
18
+ if (body?.trim()) {
19
+ parts.push(`-d '${escapeShell(body)}'`);
20
+ }
21
+ // Single line for simple requests, multi-line for complex ones
22
+ if (parts.length === 1) {
23
+ return `curl ${parts[0]}`;
24
+ }
25
+ return `curl \\\n ${parts.join(" \\\n ")}`;
26
+ },
27
+
28
+ javascript: (method, url, headers, body) => {
29
+ const hasHeaders = Object.keys(headers).length > 0;
30
+ const hasBody = body?.trim();
31
+ let code = `const response = await fetch('${url}', {\n`;
32
+ code += ` method: '${method}',\n`;
33
+ if (hasHeaders) {
34
+ code += ` headers: {\n`;
35
+ for (const [k, v] of Object.entries(headers)) {
36
+ code += ` '${k}': '${v}',\n`;
37
+ }
38
+ code += ` },\n`;
39
+ }
40
+ if (hasBody) {
41
+ code += ` body: JSON.stringify(${body}),\n`;
42
+ }
43
+ code += `});\n\n`;
44
+ code += `const data = await response.json();\n`;
45
+ code += `console.log(data);`;
46
+ return code;
47
+ },
48
+
49
+ python: (method, url, headers, body) => {
50
+ const lines = ["import requests", ""];
51
+ if (Object.keys(headers).length > 0) {
52
+ lines.push("headers = {");
53
+ for (const [k, v] of Object.entries(headers)) {
54
+ lines.push(` '${k}': '${v}',`);
55
+ }
56
+ lines.push("}", "");
57
+ }
58
+ if (body?.trim()) {
59
+ lines.push(`json_data = ${body}`, "");
60
+ }
61
+ const args = [`'${url}'`];
62
+ if (Object.keys(headers).length > 0) args.push("headers=headers");
63
+ if (body?.trim()) args.push("json=json_data");
64
+ lines.push(`response = requests.${method.toLowerCase()}(`);
65
+ lines.push(` ${args.join(",\n ")}`);
66
+ lines.push(`)`, "");
67
+ lines.push(`print(response.json())`);
68
+ return lines.join("\n");
69
+ },
70
+ };
71
+
72
+ export const getLanguageForHighlighter = (lang: "curl" | "javascript" | "python"): string => {
73
+ const map = { curl: "bash", javascript: "javascript", python: "python" };
74
+ return map[lang];
75
+ };
@@ -0,0 +1,490 @@
1
+ import React, { useState, useEffect, useCallback, useMemo } from "react";
2
+ import type {
3
+ ApiPlaygroundProps,
4
+ AuthState,
5
+ KeyValue,
6
+ ResponseState,
7
+ ErrorState,
8
+ CodeLanguage,
9
+ } from "./types";
10
+ import {
11
+ METHOD_COLORS,
12
+ AUTH_STORAGE_PREFIX,
13
+ DEFAULT_AUTH_STATE,
14
+ } from "./constants";
15
+ import { generators } from "./generators";
16
+ import { Spinner } from "./Spinner";
17
+ import { CollapsibleSection } from "./CollapsibleSection";
18
+ import { KeyValueEditor } from "./KeyValueEditor";
19
+ import { AuthSection } from "./AuthSection";
20
+ import { CodeSnippets } from "./CodeSnippets";
21
+ import { ResponseDisplay } from "./ResponseDisplay";
22
+
23
+ const EMPTY_HEADERS: Record<string, string> = {};
24
+ const EMPTY_PARAMS: Record<string, string> = {};
25
+
26
+ export default function ApiPlayground({
27
+ endpoint,
28
+ method,
29
+ baseUrl,
30
+ defaultHeaders = EMPTY_HEADERS,
31
+ defaultBody,
32
+ defaultParams = EMPTY_PARAMS,
33
+ }: ApiPlaygroundProps) {
34
+ const pathParams = useMemo(
35
+ () => (endpoint.match(/\{(\w+)\}/g) || []).map((p) => p.slice(1, -1)),
36
+ [endpoint],
37
+ );
38
+ const methodUpper = method.toUpperCase();
39
+ const supportsBody = ["POST", "PUT", "PATCH", "DELETE"].includes(methodUpper);
40
+
41
+ const [currentBaseUrl, setCurrentBaseUrl] = useState(baseUrl);
42
+ const [pathParamValues, setPathParamValues] = useState<
43
+ Record<string, string>
44
+ >(() => {
45
+ const initial: Record<string, string> = {};
46
+ pathParams.forEach((p) => (initial[p] = defaultParams[p] || ""));
47
+ return initial;
48
+ });
49
+ const [queryParams, setQueryParams] = useState<KeyValue[]>(() =>
50
+ Object.entries(defaultParams)
51
+ .filter(([key]) => !pathParams.includes(key))
52
+ .map(([key, value]) => ({ key, value })),
53
+ );
54
+ const [headers, setHeaders] = useState<KeyValue[]>(() => {
55
+ const initial = Object.entries(defaultHeaders).map(([key, value]) => ({
56
+ key,
57
+ value,
58
+ }));
59
+ if (supportsBody && !defaultHeaders["Content-Type"]) {
60
+ initial.unshift({ key: "Content-Type", value: "application/json" });
61
+ }
62
+ return initial;
63
+ });
64
+ const [requestBody, setRequestBody] = useState(() => {
65
+ if (!defaultBody) return "";
66
+ return typeof defaultBody === "string"
67
+ ? defaultBody
68
+ : JSON.stringify(defaultBody, null, 2);
69
+ });
70
+ const [isBodyValid, setIsBodyValid] = useState(true);
71
+ const [authState, setAuthState] = useState<AuthState>(() => ({
72
+ ...DEFAULT_AUTH_STATE,
73
+ }));
74
+
75
+ const [isLoading, setIsLoading] = useState(false);
76
+ const [response, setResponse] = useState<ResponseState | null>(null);
77
+ const [error, setError] = useState<ErrorState | null>(null);
78
+ const [currentLang, setCurrentLang] = useState<CodeLanguage>("curl");
79
+
80
+ const [expandedSections, setExpandedSections] = useState({
81
+ pathParams: pathParams.length > 0,
82
+ queryParams: false,
83
+ headers: false,
84
+ body: true,
85
+ auth: false,
86
+ });
87
+
88
+ useEffect(() => {
89
+ try {
90
+ const storage = localStorage;
91
+ const savedType = storage.getItem(AUTH_STORAGE_PREFIX + "type") as
92
+ | AuthState["type"]
93
+ | null;
94
+ if (
95
+ savedType &&
96
+ ["none", "bearer", "apikey", "basic"].includes(savedType)
97
+ ) {
98
+ setAuthState({
99
+ type: savedType,
100
+ bearer: {
101
+ token: storage.getItem(AUTH_STORAGE_PREFIX + "bearer_token") || "",
102
+ },
103
+ apikey: {
104
+ name: storage.getItem(AUTH_STORAGE_PREFIX + "apikey_name") || "",
105
+ value: storage.getItem(AUTH_STORAGE_PREFIX + "apikey_value") || "",
106
+ location:
107
+ (storage.getItem(AUTH_STORAGE_PREFIX + "apikey_location") as
108
+ | "header"
109
+ | "query") || "header",
110
+ },
111
+ basic: {
112
+ username:
113
+ storage.getItem(AUTH_STORAGE_PREFIX + "basic_username") || "",
114
+ password:
115
+ storage.getItem(AUTH_STORAGE_PREFIX + "basic_password") || "",
116
+ },
117
+ });
118
+ }
119
+ } catch {
120
+ // localStorage may not be available (SSR, private browsing, etc.)
121
+ }
122
+ }, []);
123
+
124
+ const saveAuth = useCallback((newAuth: AuthState) => {
125
+ setAuthState(newAuth);
126
+ try {
127
+ const storage = localStorage;
128
+ storage.setItem(AUTH_STORAGE_PREFIX + "type", newAuth.type);
129
+ storage.setItem(
130
+ AUTH_STORAGE_PREFIX + "bearer_token",
131
+ newAuth.bearer.token,
132
+ );
133
+ storage.setItem(AUTH_STORAGE_PREFIX + "apikey_name", newAuth.apikey.name);
134
+ storage.setItem(
135
+ AUTH_STORAGE_PREFIX + "apikey_value",
136
+ newAuth.apikey.value,
137
+ );
138
+ storage.setItem(
139
+ AUTH_STORAGE_PREFIX + "apikey_location",
140
+ newAuth.apikey.location,
141
+ );
142
+ storage.setItem(
143
+ AUTH_STORAGE_PREFIX + "basic_username",
144
+ newAuth.basic.username,
145
+ );
146
+ storage.setItem(
147
+ AUTH_STORAGE_PREFIX + "basic_password",
148
+ newAuth.basic.password,
149
+ );
150
+ } catch {
151
+ // localStorage not available
152
+ }
153
+ }, []);
154
+
155
+ const processedEndpoint = useMemo(() => {
156
+ let processed = endpoint;
157
+ Object.entries(pathParamValues).forEach(([param, value]) => {
158
+ if (value)
159
+ processed = processed.replace(`{${param}}`, encodeURIComponent(value));
160
+ });
161
+ return processed;
162
+ }, [endpoint, pathParamValues]);
163
+
164
+ const queryString = useMemo(() => {
165
+ const params = new URLSearchParams();
166
+ queryParams.forEach((p) => {
167
+ if (p.key && p.value) params.append(p.key, p.value);
168
+ });
169
+ if (
170
+ authState.type === "apikey" &&
171
+ authState.apikey.location === "query" &&
172
+ authState.apikey.name &&
173
+ authState.apikey.value
174
+ ) {
175
+ params.append(authState.apikey.name, authState.apikey.value);
176
+ }
177
+ const str = params.toString();
178
+ return str ? `?${str}` : "";
179
+ }, [queryParams, authState]);
180
+
181
+ const requestHeaders = useMemo(() => {
182
+ const reqHeaders: Record<string, string> = {};
183
+ headers.forEach((h) => {
184
+ if (h.key && h.value) reqHeaders[h.key] = h.value;
185
+ });
186
+ if (authState.type === "bearer" && authState.bearer.token) {
187
+ reqHeaders["Authorization"] = `Bearer ${authState.bearer.token}`;
188
+ } else if (
189
+ authState.type === "apikey" &&
190
+ authState.apikey.location === "header" &&
191
+ authState.apikey.name &&
192
+ authState.apikey.value
193
+ ) {
194
+ reqHeaders[authState.apikey.name] = authState.apikey.value;
195
+ } else if (authState.type === "basic" && authState.basic.username) {
196
+ reqHeaders["Authorization"] = `Basic ${btoa(
197
+ `${authState.basic.username}:${authState.basic.password}`,
198
+ )}`;
199
+ }
200
+ return reqHeaders;
201
+ }, [headers, authState]);
202
+
203
+ const fullUrl = currentBaseUrl + processedEndpoint + queryString;
204
+
205
+ const codeSnippet = useMemo(() => {
206
+ const body = supportsBody ? requestBody : undefined;
207
+ return generators[currentLang](methodUpper, fullUrl, requestHeaders, body);
208
+ }, [
209
+ currentLang,
210
+ methodUpper,
211
+ fullUrl,
212
+ requestHeaders,
213
+ supportsBody,
214
+ requestBody,
215
+ ]);
216
+
217
+ const sendRequest = async () => {
218
+ setIsLoading(true);
219
+ setError(null);
220
+ setResponse(null);
221
+ const startTime = performance.now();
222
+
223
+ try {
224
+ const fetchOptions: RequestInit = {
225
+ method: methodUpper,
226
+ headers: requestHeaders,
227
+ };
228
+ if (supportsBody && requestBody.trim()) fetchOptions.body = requestBody;
229
+
230
+ const res = await fetch(fullUrl, fetchOptions);
231
+ const endTime = performance.now();
232
+ const text = await res.text();
233
+ const resHeaders: Record<string, string> = {};
234
+ res.headers.forEach((value, key) => (resHeaders[key] = value));
235
+
236
+ setResponse({
237
+ status: res.status,
238
+ statusText: res.statusText,
239
+ body: text,
240
+ headers: resHeaders,
241
+ time: Math.round(endTime - startTime),
242
+ });
243
+ } catch (err) {
244
+ if (err instanceof TypeError && err.message.includes("Failed to fetch")) {
245
+ setError({
246
+ title: "Network Error",
247
+ message:
248
+ "Failed to fetch. This could be a CORS issue. Make sure the API server has proper CORS headers configured.",
249
+ });
250
+ } else {
251
+ setError({
252
+ title: "Error",
253
+ message:
254
+ err instanceof Error ? err.message : "An unknown error occurred",
255
+ });
256
+ }
257
+ } finally {
258
+ setIsLoading(false);
259
+ }
260
+ };
261
+
262
+ const validateBody = (value: string) => {
263
+ if (!value.trim()) {
264
+ setIsBodyValid(true);
265
+ return;
266
+ }
267
+ try {
268
+ JSON.parse(value);
269
+ setIsBodyValid(true);
270
+ } catch {
271
+ setIsBodyValid(false);
272
+ }
273
+ };
274
+
275
+ const formatBody = () => {
276
+ try {
277
+ const parsed = JSON.parse(requestBody);
278
+ setRequestBody(JSON.stringify(parsed, null, 2));
279
+ setIsBodyValid(true);
280
+ } catch {
281
+ setIsBodyValid(false);
282
+ }
283
+ };
284
+
285
+ const resetDefaults = () => {
286
+ setCurrentBaseUrl(baseUrl);
287
+ const initialPathParams: Record<string, string> = {};
288
+ pathParams.forEach((p) => (initialPathParams[p] = defaultParams[p] || ""));
289
+ setPathParamValues(initialPathParams);
290
+ setQueryParams(
291
+ Object.entries(defaultParams)
292
+ .filter(([key]) => !pathParams.includes(key))
293
+ .map(([key, value]) => ({ key, value })),
294
+ );
295
+ const initialHeaders = Object.entries(defaultHeaders).map(
296
+ ([key, value]) => ({ key, value }),
297
+ );
298
+ if (supportsBody && !defaultHeaders["Content-Type"]) {
299
+ initialHeaders.unshift({
300
+ key: "Content-Type",
301
+ value: "application/json",
302
+ });
303
+ }
304
+ setHeaders(initialHeaders);
305
+ setRequestBody(
306
+ defaultBody
307
+ ? typeof defaultBody === "string"
308
+ ? defaultBody
309
+ : JSON.stringify(defaultBody, null, 2)
310
+ : "",
311
+ );
312
+ setIsBodyValid(true);
313
+ };
314
+
315
+ const toggleSection = (section: keyof typeof expandedSections) => {
316
+ setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
317
+ };
318
+
319
+ return (
320
+ <div className="api-playground my-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden max-w-full">
321
+ <div className="px-3 sm:px-4 py-3 bg-[var(--color-surface-raised)] border-b border-[var(--color-border)]">
322
+ <h3 className="text-sm font-semibold text-[var(--color-foreground)] m-0">
323
+ API Playground
324
+ </h3>
325
+ </div>
326
+
327
+ <div className="p-3 sm:p-4 border-b border-[var(--color-border)] max-w-full overflow-hidden">
328
+ <div className="flex flex-wrap items-center gap-2 mb-4">
329
+ <span
330
+ className={`px-2 py-1 text-xs font-mono font-bold rounded uppercase flex-shrink-0 ${
331
+ METHOD_COLORS[methodUpper] ||
332
+ "bg-[var(--color-surface-raised)] text-[var(--color-muted)]"
333
+ }`}
334
+ >
335
+ {method}
336
+ </span>
337
+ <code className="text-xs sm:text-sm font-mono text-[var(--color-muted)] break-all">
338
+ {endpoint}
339
+ </code>
340
+ </div>
341
+
342
+ <div className="mb-3">
343
+ <label className="block text-xs font-medium text-[var(--color-muted)] mb-1">
344
+ Base URL
345
+ </label>
346
+ <input
347
+ type="text"
348
+ value={currentBaseUrl}
349
+ onChange={(e) => setCurrentBaseUrl(e.target.value)}
350
+ className="w-full px-3 py-2 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-surface)] text-[var(--color-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] font-mono"
351
+ />
352
+ </div>
353
+
354
+ <div className="mb-4">
355
+ <label className="block text-xs font-medium text-[var(--color-muted)] mb-1">
356
+ Full URL Preview
357
+ </label>
358
+ <div className="px-3 py-2 text-xs sm:text-sm font-mono bg-[var(--color-surface-raised)] border border-[var(--color-border)] rounded-md text-[var(--color-muted)] overflow-x-auto">
359
+ <span className="break-all">{fullUrl}</span>
360
+ </div>
361
+ </div>
362
+
363
+ {pathParams.length > 0 && (
364
+ <CollapsibleSection
365
+ title="Path Parameters"
366
+ expanded={expandedSections.pathParams}
367
+ onToggle={() => toggleSection("pathParams")}
368
+ >
369
+ <div className="space-y-3">
370
+ {pathParams.map((param) => (
371
+ <div
372
+ key={param}
373
+ className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2"
374
+ >
375
+ <label className="text-xs font-mono text-[var(--color-muted)] sm:w-24 flex-shrink-0">{`{${param}}`}</label>
376
+ <input
377
+ type="text"
378
+ value={pathParamValues[param] || ""}
379
+ onChange={(e) =>
380
+ setPathParamValues((prev) => ({
381
+ ...prev,
382
+ [param]: e.target.value,
383
+ }))
384
+ }
385
+ placeholder={`Enter ${param}`}
386
+ className="flex-1 px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-surface)] text-[var(--color-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] font-mono"
387
+ />
388
+ </div>
389
+ ))}
390
+ </div>
391
+ </CollapsibleSection>
392
+ )}
393
+
394
+ <CollapsibleSection
395
+ title="Query Parameters"
396
+ expanded={expandedSections.queryParams}
397
+ onToggle={() => toggleSection("queryParams")}
398
+ >
399
+ <KeyValueEditor
400
+ items={queryParams}
401
+ onChange={setQueryParams}
402
+ keyPlaceholder="Parameter name"
403
+ valuePlaceholder="Value"
404
+ />
405
+ </CollapsibleSection>
406
+
407
+ <CollapsibleSection
408
+ title="Headers"
409
+ expanded={expandedSections.headers}
410
+ onToggle={() => toggleSection("headers")}
411
+ >
412
+ <KeyValueEditor
413
+ items={headers}
414
+ onChange={setHeaders}
415
+ keyPlaceholder="Header name"
416
+ valuePlaceholder="Value"
417
+ />
418
+ </CollapsibleSection>
419
+
420
+ {supportsBody && (
421
+ <CollapsibleSection
422
+ title="Request Body"
423
+ expanded={expandedSections.body}
424
+ onToggle={() => toggleSection("body")}
425
+ >
426
+ <div className="space-y-2">
427
+ <textarea
428
+ value={requestBody}
429
+ onChange={(e) => setRequestBody(e.target.value)}
430
+ onBlur={() => validateBody(requestBody)}
431
+ placeholder='{"key": "value"}'
432
+ className="w-full h-32 px-3 py-2 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-surface)] text-[var(--color-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] font-mono resize-y"
433
+ />
434
+ {!isBodyValid && (
435
+ <p className="text-xs text-[var(--color-error)]">
436
+ Invalid JSON
437
+ </p>
438
+ )}
439
+ <button
440
+ type="button"
441
+ onClick={formatBody}
442
+ className="text-sm text-[var(--color-primary)] hover:underline"
443
+ >
444
+ Format JSON
445
+ </button>
446
+ </div>
447
+ </CollapsibleSection>
448
+ )}
449
+
450
+ <CollapsibleSection
451
+ title="Authentication"
452
+ expanded={expandedSections.auth}
453
+ onToggle={() => toggleSection("auth")}
454
+ >
455
+ <AuthSection authState={authState} onAuthChange={saveAuth} />
456
+ </CollapsibleSection>
457
+
458
+ <div className="mt-4 flex flex-col sm:flex-row gap-2 sm:gap-3">
459
+ <button
460
+ type="button"
461
+ onClick={sendRequest}
462
+ disabled={isLoading}
463
+ className="px-4 py-2 text-sm font-medium text-[var(--color-primary-foreground)] bg-[var(--color-primary)] hover:opacity-90 rounded-md disabled:opacity-50 flex items-center justify-center gap-2"
464
+ >
465
+ {isLoading && <Spinner />}
466
+ {isLoading ? "Sending..." : "Send Request"}
467
+ </button>
468
+ <button
469
+ type="button"
470
+ onClick={resetDefaults}
471
+ className="px-4 py-2 text-sm font-medium text-[var(--color-muted)] bg-[var(--color-surface-raised)] hover:bg-[var(--color-surface-sunken)] rounded-md border border-[var(--color-border)]"
472
+ >
473
+ Reset to Defaults
474
+ </button>
475
+ </div>
476
+ </div>
477
+
478
+ <CodeSnippets
479
+ codeSnippet={codeSnippet}
480
+ currentLang={currentLang}
481
+ onLangChange={setCurrentLang}
482
+ />
483
+ <ResponseDisplay
484
+ response={response}
485
+ error={error}
486
+ isLoading={isLoading}
487
+ />
488
+ </div>
489
+ );
490
+ }