@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,135 @@
1
+ ---
2
+ /**
3
+ * Tabs - Content tabs component
4
+ * Server-renders tab buttons to eliminate FOUC
5
+ * Uses Web Components for click handlers
6
+ */
7
+ interface TabInfo {
8
+ label: string;
9
+ icon?: string;
10
+ }
11
+
12
+ interface Props {
13
+ "data-tabs"?: string;
14
+ class?: string;
15
+ }
16
+
17
+ const { "data-tabs": tabsJson, class: className } = Astro.props;
18
+
19
+ // Parse tabs from remark plugin (or empty array for fallback)
20
+ const tabs: TabInfo[] = tabsJson ? JSON.parse(tabsJson) : [];
21
+ const showTabs = tabs.length > 1;
22
+ ---
23
+
24
+ <content-tabs
25
+ class:list={["tabs-container my-6 block", className]}
26
+ data-tabs={tabsJson}
27
+ >
28
+ {showTabs && (
29
+ <nav
30
+ class="tabs-nav flex border-b border-[var(--color-border)]"
31
+ role="tablist"
32
+ aria-label="Content tabs"
33
+ >
34
+ {tabs.map((tab, i) => (
35
+ <button
36
+ type="button"
37
+ role="tab"
38
+ class:list={[
39
+ "px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px",
40
+ i === 0
41
+ ? "border-[var(--color-primary)] text-[var(--color-primary)]"
42
+ : "border-transparent text-[var(--color-muted)] hover:text-[var(--color-foreground)] hover:border-[var(--color-border)]",
43
+ ]}
44
+ aria-selected={i === 0 ? "true" : "false"}
45
+ tabindex={i === 0 ? 0 : -1}
46
+ data-index={i}
47
+ >
48
+ {tab.icon && (
49
+ <span class="mr-2" aria-hidden="true">
50
+ {tab.icon}
51
+ </span>
52
+ )}
53
+ {tab.label}
54
+ </button>
55
+ ))}
56
+ </nav>
57
+ )}
58
+ <div class="tabs-panels pt-4">
59
+ <slot />
60
+ </div>
61
+ </content-tabs>
62
+
63
+ <script>
64
+ class ContentTabsElement extends HTMLElement {
65
+ private panels: HTMLElement[] = [];
66
+
67
+ connectedCallback() {
68
+ const panelsContainer = this.querySelector(".tabs-panels");
69
+ if (!panelsContainer) return;
70
+
71
+ this.panels = Array.from(
72
+ panelsContainer.querySelectorAll(":scope > .tab-panel")
73
+ );
74
+
75
+ if (this.panels.length === 0) return;
76
+
77
+ // Initialize panels with data-active attribute
78
+ this.initPanels();
79
+
80
+ // If only one panel, just mark it active
81
+ if (this.panels.length === 1) {
82
+ this.panels[0].setAttribute("data-active", "true");
83
+ return;
84
+ }
85
+
86
+ // Add click handlers to server-rendered buttons
87
+ this.attachClickHandlers();
88
+ }
89
+
90
+ private initPanels() {
91
+ this.panels.forEach((panel, index) => {
92
+ panel.setAttribute("role", "tabpanel");
93
+ panel.setAttribute("data-active", String(index === 0));
94
+ });
95
+ }
96
+
97
+ private attachClickHandlers() {
98
+ const buttons = this.querySelectorAll<HTMLButtonElement>(
99
+ ".tabs-nav button"
100
+ );
101
+ buttons.forEach((btn, index) => {
102
+ btn.addEventListener("click", () => this.setActiveTab(index));
103
+ });
104
+ }
105
+
106
+ private setActiveTab(index: number) {
107
+ // Update buttons
108
+ const buttons = this.querySelectorAll<HTMLButtonElement>(
109
+ ".tabs-nav button"
110
+ );
111
+ buttons.forEach((btn, i) => {
112
+ const isActive = i === index;
113
+ btn.className = this.getTabClass(isActive);
114
+ btn.setAttribute("aria-selected", String(isActive));
115
+ btn.tabIndex = isActive ? 0 : -1;
116
+ });
117
+
118
+ // Update panels
119
+ this.panels.forEach((panel, i) => {
120
+ panel.setAttribute("data-active", String(i === index));
121
+ });
122
+ }
123
+
124
+ private getTabClass(isActive: boolean): string {
125
+ const base =
126
+ "px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px";
127
+ const active = "border-[var(--color-primary)] text-[var(--color-primary)]";
128
+ const inactive =
129
+ "border-transparent text-[var(--color-muted)] hover:text-[var(--color-foreground)] hover:border-[var(--color-border)]";
130
+ return `${base} ${isActive ? active : inactive}`;
131
+ }
132
+ }
133
+
134
+ customElements.define("content-tabs", ContentTabsElement);
135
+ </script>
@@ -0,0 +1,107 @@
1
+ /**
2
+ * MDX Components for opendocs
3
+ *
4
+ * These Astro components are available for use in MDX files:
5
+ *
6
+ * - Callout: Info, warning, error, and tip callouts
7
+ * Props: type?: "info" | "warning" | "error" | "tip"
8
+ *
9
+ * - CodeGroup: Tabbed code blocks
10
+ * Props: labels?: string[]
11
+ *
12
+ * - Card: Linked content cards with title and description
13
+ * Props: title: string, description?: string, href?: string, icon?: string
14
+ *
15
+ * - CardGroup: Grid layout wrapper for Card components
16
+ * Props: cols?: 1 | 2 | 3 | 4 (default: 2)
17
+ *
18
+ * - Steps: Numbered step lists with vertical line connector
19
+ * Props: title?: string
20
+ *
21
+ * - Tabs: Tabbed content sections for organizing related content
22
+ * Usage: <Tabs><Tab label="First" icon="🚀">Content</Tab></Tabs>
23
+ *
24
+ * Usage in MDX:
25
+ * ```mdx
26
+ * import { Callout, CodeGroup, Card, Steps } from '../components';
27
+ *
28
+ * <Callout type="info">This is an info callout</Callout>
29
+ *
30
+ * <CodeGroup labels={["npm", "yarn", "pnpm"]}>
31
+ * ```bash
32
+ * npm install
33
+ * ```
34
+ * ```bash
35
+ * yarn
36
+ * ```
37
+ * ```bash
38
+ * pnpm install
39
+ * ```
40
+ * </CodeGroup>
41
+ *
42
+ * <Card title="Getting Started" description="Learn how to get started" href="/getting-started" />
43
+ *
44
+ * <Steps>
45
+ * ### Step 1
46
+ * First, do this...
47
+ *
48
+ * ### Step 2
49
+ * Then, do that...
50
+ * </Steps>
51
+ * ```
52
+ */
53
+
54
+ // Component type definitions for documentation
55
+ export interface CalloutProps {
56
+ type?: "info" | "warning" | "error" | "tip";
57
+ }
58
+
59
+ export interface CodeGroupProps {
60
+ labels?: string[];
61
+ }
62
+
63
+ export interface CardProps {
64
+ title: string;
65
+ description?: string;
66
+ href?: string;
67
+ icon?: string;
68
+ }
69
+
70
+ export interface CardGroupProps {
71
+ cols?: 1 | 2 | 3 | 4;
72
+ }
73
+
74
+ export interface StepsProps {
75
+ title?: string;
76
+ }
77
+
78
+ export interface TabsProps {
79
+ // Tabs container has no props, it wraps Tab children
80
+ }
81
+
82
+ export interface TabProps {
83
+ label: string;
84
+ icon?: string;
85
+ }
86
+
87
+ /**
88
+ * Analytics Component Props
89
+ * Tracks pageviews to the backend analytics endpoint
90
+ */
91
+ export interface AnalyticsProps {
92
+ path?: string;
93
+ }
94
+
95
+ // Export component names for reference (actual components are .astro files)
96
+ export const componentNames = [
97
+ "Callout",
98
+ "CodeGroup",
99
+ "Card",
100
+ "CardGroup",
101
+ "Steps",
102
+ "Tabs",
103
+ "Tab",
104
+ "Sidebar",
105
+ "TableOfContents",
106
+ "Analytics",
107
+ ] as const;
@@ -0,0 +1,91 @@
1
+ import React from "react";
2
+ import type { AuthState } from "./types";
3
+ import { DEFAULT_AUTH_STATE } from "./constants";
4
+
5
+ interface AuthSectionProps {
6
+ authState: AuthState;
7
+ onAuthChange: (auth: AuthState) => void;
8
+ }
9
+
10
+ export function AuthSection({ authState, onAuthChange }: AuthSectionProps) {
11
+ return (
12
+ <div className="space-y-3 max-w-full overflow-hidden">
13
+ <select
14
+ value={authState.type}
15
+ onChange={(e) => onAuthChange({ ...authState, type: e.target.value as AuthState["type"] })}
16
+ 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)]"
17
+ >
18
+ <option value="none">No Authentication</option>
19
+ <option value="bearer">Bearer Token</option>
20
+ <option value="apikey">API Key</option>
21
+ <option value="basic">Basic Auth</option>
22
+ </select>
23
+
24
+ {authState.type === "bearer" && (
25
+ <input
26
+ type="password"
27
+ value={authState.bearer.token}
28
+ onChange={(e) => onAuthChange({ ...authState, bearer: { token: e.target.value } })}
29
+ placeholder="Enter token"
30
+ className="w-full 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"
31
+ />
32
+ )}
33
+
34
+ {authState.type === "apikey" && (
35
+ <div className="space-y-2">
36
+ <input
37
+ type="text"
38
+ value={authState.apikey.name}
39
+ onChange={(e) => onAuthChange({ ...authState, apikey: { ...authState.apikey, name: e.target.value } })}
40
+ placeholder="Key name (e.g., X-API-Key)"
41
+ className="w-full 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"
42
+ />
43
+ <input
44
+ type="password"
45
+ value={authState.apikey.value}
46
+ onChange={(e) => onAuthChange({ ...authState, apikey: { ...authState.apikey, value: e.target.value } })}
47
+ placeholder="Key value"
48
+ className="w-full 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"
49
+ />
50
+ <select
51
+ value={authState.apikey.location}
52
+ onChange={(e) => onAuthChange({ ...authState, apikey: { ...authState.apikey, location: e.target.value as "header" | "query" } })}
53
+ className="w-full 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)]"
54
+ >
55
+ <option value="header">Header</option>
56
+ <option value="query">Query Parameter</option>
57
+ </select>
58
+ </div>
59
+ )}
60
+
61
+ {authState.type === "basic" && (
62
+ <div className="space-y-2">
63
+ <input
64
+ type="text"
65
+ value={authState.basic.username}
66
+ onChange={(e) => onAuthChange({ ...authState, basic: { ...authState.basic, username: e.target.value } })}
67
+ placeholder="Username"
68
+ className="w-full 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"
69
+ />
70
+ <input
71
+ type="password"
72
+ value={authState.basic.password}
73
+ onChange={(e) => onAuthChange({ ...authState, basic: { ...authState.basic, password: e.target.value } })}
74
+ placeholder="Password"
75
+ className="w-full 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"
76
+ />
77
+ </div>
78
+ )}
79
+
80
+ {authState.type !== "none" && (
81
+ <button
82
+ type="button"
83
+ onClick={() => onAuthChange(DEFAULT_AUTH_STATE)}
84
+ className="text-sm text-[var(--color-muted)] hover:text-[var(--color-error)]"
85
+ >
86
+ Clear Saved Auth
87
+ </button>
88
+ )}
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,66 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+
3
+ interface CodeBlockProps {
4
+ code: string;
5
+ language: string;
6
+ className?: string;
7
+ }
8
+
9
+ // Cache for highlighted code to avoid re-highlighting same content
10
+ const highlightCache = new Map<string, string>();
11
+
12
+ export function CodeBlock({ code, language, className = "" }: CodeBlockProps) {
13
+ const [html, setHtml] = useState<string>("");
14
+ const shikiRef = useRef<typeof import("shiki") | null>(null);
15
+
16
+ useEffect(() => {
17
+ let cancelled = false;
18
+ const cacheKey = `${language}:${code}`;
19
+
20
+ // Check cache first
21
+ const cached = highlightCache.get(cacheKey);
22
+ if (cached) {
23
+ setHtml(cached);
24
+ return;
25
+ }
26
+
27
+ // Dynamically import shiki only when needed (bundle-dynamic-imports)
28
+ const highlight = async () => {
29
+ if (!shikiRef.current) {
30
+ shikiRef.current = await import("shiki");
31
+ }
32
+ const result = await shikiRef.current.codeToHtml(code, {
33
+ lang: language,
34
+ themes: {
35
+ light: "github-light",
36
+ dark: "github-dark",
37
+ },
38
+ defaultColor: false,
39
+ });
40
+ if (!cancelled) {
41
+ highlightCache.set(cacheKey, result);
42
+ setHtml(result);
43
+ }
44
+ };
45
+
46
+ highlight();
47
+ return () => {
48
+ cancelled = true;
49
+ };
50
+ }, [code, language]);
51
+
52
+ if (!html) {
53
+ return (
54
+ <pre className={`text-sm font-mono bg-[var(--color-surface-raised)] text-[var(--color-foreground)] p-3 sm:p-4 rounded-xl overflow-x-auto border border-[var(--color-border)] max-w-full ${className}`}>
55
+ <code className="break-all whitespace-pre-wrap sm:whitespace-pre">{code}</code>
56
+ </pre>
57
+ );
58
+ }
59
+
60
+ return (
61
+ <div
62
+ className={`max-w-full overflow-hidden [&>pre]:!m-0 [&>pre]:!rounded-xl [&>pre]:!p-3 [&>pre]:sm:!p-4 [&>pre]:text-sm [&>pre]:overflow-x-auto [&>pre]:!bg-[var(--color-surface-raised)] [&>pre]:border [&>pre]:border-[var(--color-border)] [&>pre]:max-w-full [&_code]:break-all [&_code]:whitespace-pre-wrap [&_code]:sm:whitespace-pre ${className}`}
63
+ dangerouslySetInnerHTML={{ __html: html }}
64
+ />
65
+ );
66
+ }
@@ -0,0 +1,66 @@
1
+ import React, { useState } from "react";
2
+ import type { CodeLanguage } from "./types";
3
+ import { CodeBlock } from "./CodeBlock";
4
+ import { getLanguageForHighlighter } from "./generators";
5
+
6
+ interface CodeSnippetsProps {
7
+ codeSnippet: string;
8
+ currentLang: CodeLanguage;
9
+ onLangChange: (lang: CodeLanguage) => void;
10
+ }
11
+
12
+ const LANGUAGES: { value: CodeLanguage; label: string }[] = [
13
+ { value: "curl", label: "curl" },
14
+ { value: "javascript", label: "JavaScript" },
15
+ { value: "python", label: "Python" },
16
+ ];
17
+
18
+ export function CodeSnippets({ codeSnippet, currentLang, onLangChange }: CodeSnippetsProps) {
19
+ const [copied, setCopied] = useState(false);
20
+
21
+ const copyCode = async () => {
22
+ try {
23
+ await navigator.clipboard.writeText(codeSnippet);
24
+ setCopied(true);
25
+ setTimeout(() => setCopied(false), 2000);
26
+ } catch (err) {
27
+ console.debug("Failed to copy to clipboard:", err);
28
+ }
29
+ };
30
+
31
+ return (
32
+ <div className="p-3 sm:p-4 border-b border-[var(--color-border)] max-w-full overflow-hidden">
33
+ <h4 className="text-xs font-semibold text-[var(--color-muted)] uppercase tracking-wider mb-3">
34
+ Code Snippets
35
+ </h4>
36
+
37
+ <div className="flex flex-wrap gap-1 mb-3 max-w-full">
38
+ {LANGUAGES.map(({ value, label }) => (
39
+ <button
40
+ key={value}
41
+ type="button"
42
+ onClick={() => onLangChange(value)}
43
+ className={`px-2 sm:px-3 py-1 sm:py-1.5 text-xs sm:text-sm font-medium rounded-md transition-colors ${
44
+ currentLang === value
45
+ ? "bg-[var(--color-primary-light)] text-[var(--color-primary)]"
46
+ : "text-[var(--color-muted)] hover:text-[var(--color-foreground)] hover:bg-[var(--color-surface-raised)]"
47
+ }`}
48
+ >
49
+ {label}
50
+ </button>
51
+ ))}
52
+ </div>
53
+
54
+ <div className="relative max-w-full overflow-hidden">
55
+ <CodeBlock code={codeSnippet} language={getLanguageForHighlighter(currentLang)} />
56
+ <button
57
+ type="button"
58
+ onClick={copyCode}
59
+ className="absolute top-2 right-2 px-1.5 sm:px-2 py-0.5 sm:py-1 text-[10px] sm:text-xs font-medium text-[var(--color-muted)] hover:text-[var(--color-foreground)] bg-[var(--color-surface-raised)] hover:bg-[var(--color-surface-sunken)] border border-[var(--color-border)] rounded z-10"
60
+ >
61
+ {copied ? "Copied!" : "Copy"}
62
+ </button>
63
+ </div>
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { ChevronDown } from "lucide-react";
3
+
4
+ interface CollapsibleSectionProps {
5
+ title: string;
6
+ expanded: boolean;
7
+ onToggle: () => void;
8
+ children: React.ReactNode;
9
+ className?: string;
10
+ }
11
+
12
+ export function CollapsibleSection({ title, expanded, onToggle, children, className = "" }: CollapsibleSectionProps) {
13
+ return (
14
+ <div className={`mt-4 border-t border-[var(--color-border)] pt-4 max-w-full overflow-hidden ${className}`}>
15
+ <button
16
+ type="button"
17
+ onClick={onToggle}
18
+ className="flex items-center justify-between w-full text-left py-2 text-sm font-medium text-[var(--color-foreground)] hover:text-[var(--color-muted)]"
19
+ >
20
+ <span className="truncate mr-2">{title}</span>
21
+ <ChevronDown className={`w-4 h-4 flex-shrink-0 transition-transform ${expanded ? "rotate-180" : ""}`} />
22
+ </button>
23
+ {expanded && <div className="mt-2 max-w-full overflow-hidden">{children}</div>}
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,58 @@
1
+ import React from "react";
2
+ import { X, Plus } from "lucide-react";
3
+ import type { KeyValue } from "./types";
4
+
5
+ interface KeyValueEditorProps {
6
+ items: KeyValue[];
7
+ onChange: (items: KeyValue[]) => void;
8
+ keyPlaceholder: string;
9
+ valuePlaceholder: string;
10
+ }
11
+
12
+ export function KeyValueEditor({ items, onChange, keyPlaceholder, valuePlaceholder }: KeyValueEditorProps) {
13
+ const updateItem = (index: number, field: "key" | "value", value: string) => {
14
+ const newItems = [...items];
15
+ newItems[index] = { ...newItems[index], [field]: value };
16
+ onChange(newItems);
17
+ };
18
+
19
+ const removeItem = (index: number) => {
20
+ onChange(items.filter((_, i) => i !== index));
21
+ };
22
+
23
+ const addItem = () => {
24
+ onChange([...items, { key: "", value: "" }]);
25
+ };
26
+
27
+ return (
28
+ <div className="space-y-3 max-w-full overflow-hidden">
29
+ {items.map((item, index) => (
30
+ <div key={index} className="flex flex-col sm:flex-row gap-2 max-w-full">
31
+ <input
32
+ type="text"
33
+ value={item.key}
34
+ onChange={(e) => updateItem(index, "key", e.target.value)}
35
+ placeholder={keyPlaceholder}
36
+ className="w-full sm:flex-1 sm:w-auto min-w-0 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"
37
+ />
38
+ <div className="flex gap-2 max-w-full">
39
+ <input
40
+ type="text"
41
+ value={item.value}
42
+ onChange={(e) => updateItem(index, "value", e.target.value)}
43
+ placeholder={valuePlaceholder}
44
+ className="flex-1 min-w-0 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"
45
+ />
46
+ <button type="button" onClick={() => removeItem(index)} className="p-1.5 text-[var(--color-muted)] hover:text-[var(--color-error)] flex-shrink-0" title="Remove">
47
+ <X className="w-4 h-4" />
48
+ </button>
49
+ </div>
50
+ </div>
51
+ ))}
52
+ <button type="button" onClick={addItem} className="flex items-center gap-1 text-sm text-[var(--color-primary)] hover:opacity-80">
53
+ <Plus className="w-4 h-4" />
54
+ Add
55
+ </button>
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,109 @@
1
+ import React, { useState, useMemo } from "react";
2
+ import type { ResponseState, ErrorState } from "./types";
3
+ import { CodeBlock } from "./CodeBlock";
4
+ import { CollapsibleSection } from "./CollapsibleSection";
5
+ import { Spinner } from "./Spinner";
6
+
7
+ interface ResponseDisplayProps {
8
+ response: ResponseState | null;
9
+ error: ErrorState | null;
10
+ isLoading: boolean;
11
+ }
12
+
13
+ const getStatusColor = (status: number) => {
14
+ if (status >= 200 && status < 300) return "bg-[var(--color-success-light)] text-[var(--color-success)]";
15
+ if (status >= 400 && status < 500) return "bg-[var(--color-warning-light)] text-[var(--color-warning)]";
16
+ if (status >= 500) return "bg-[var(--color-error-light)] text-[var(--color-error)]";
17
+ return "bg-[var(--color-surface-raised)] text-[var(--color-muted)]";
18
+ };
19
+
20
+ export function ResponseDisplay({ response, error, isLoading }: ResponseDisplayProps) {
21
+ const [showHeaders, setShowHeaders] = useState(false);
22
+ const [copied, setCopied] = useState(false);
23
+
24
+ const formattedBody = useMemo(() => {
25
+ if (!response?.body) return "";
26
+ try {
27
+ // Attempt to pretty-print JSON responses
28
+ return JSON.stringify(JSON.parse(response.body), null, 2);
29
+ } catch {
30
+ // Not valid JSON, return raw response body
31
+ return response.body;
32
+ }
33
+ }, [response?.body]);
34
+
35
+ const copyResponse = async () => {
36
+ try {
37
+ await navigator.clipboard.writeText(formattedBody);
38
+ setCopied(true);
39
+ setTimeout(() => setCopied(false), 2000);
40
+ } catch (err) {
41
+ console.debug("Failed to copy to clipboard:", err);
42
+ }
43
+ };
44
+
45
+ return (
46
+ <div className="p-3 sm:p-4 max-w-full overflow-hidden">
47
+ <h4 className="text-xs font-semibold text-[var(--color-muted)] uppercase tracking-wider mb-3">Response</h4>
48
+ <div className="min-h-[100px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 sm:p-4 max-w-full overflow-hidden">
49
+ {/* Use ternary for cleaner conditional rendering */}
50
+ {!response && !error && !isLoading ? (
51
+ <p className="text-sm text-[var(--color-muted)] italic">Click "Send Request" to see the response</p>
52
+ ) : null}
53
+
54
+ {isLoading ? (
55
+ <div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
56
+ <Spinner />
57
+ <span>Sending request...</span>
58
+ </div>
59
+ ) : null}
60
+
61
+ {error ? (
62
+ <div className="p-3 rounded-md bg-[var(--color-error-light)] border border-[var(--color-error)]">
63
+ <p className="text-sm font-medium text-[var(--color-error)]">{error.title}</p>
64
+ <p className="text-sm text-[var(--color-error)] mt-1 opacity-80">{error.message}</p>
65
+ </div>
66
+ ) : null}
67
+
68
+ {response ? (
69
+ <div>
70
+ <div className="flex flex-wrap items-center gap-3 mb-3">
71
+ <span className={`px-2 py-1 text-xs font-mono font-bold rounded ${getStatusColor(response.status)}`}>
72
+ {response.status}
73
+ </span>
74
+ <span className="text-sm text-[var(--color-muted)]">{response.statusText}</span>
75
+ <span className="text-xs text-[var(--color-muted)]">{response.time}ms</span>
76
+ </div>
77
+
78
+ <div className="relative max-w-full overflow-hidden">
79
+ <CodeBlock code={formattedBody} language="json" className="max-h-[400px]" />
80
+ <button
81
+ type="button"
82
+ onClick={copyResponse}
83
+ className="absolute top-2 right-2 px-1.5 sm:px-2 py-0.5 sm:py-1 text-[10px] sm:text-xs font-medium text-[var(--color-muted)] hover:text-[var(--color-foreground)] bg-[var(--color-surface-raised)] hover:bg-[var(--color-surface-sunken)] border border-[var(--color-border)] rounded z-10"
84
+ >
85
+ {copied ? "Copied!" : "Copy"}
86
+ </button>
87
+ </div>
88
+
89
+ <CollapsibleSection
90
+ title="Response Headers"
91
+ expanded={showHeaders}
92
+ onToggle={() => setShowHeaders(!showHeaders)}
93
+ className="mt-4 border-t border-[var(--color-border)] pt-4"
94
+ >
95
+ <div className="text-xs sm:text-sm font-mono space-y-1 overflow-x-auto max-w-full">
96
+ {Object.entries(response.headers).map(([key, value]) => (
97
+ <div key={key} className="flex flex-col sm:flex-row sm:gap-2 py-1">
98
+ <span className="text-[var(--color-muted)] flex-shrink-0">{key}:</span>
99
+ <span className="text-[var(--color-foreground)] break-all">{value}</span>
100
+ </div>
101
+ ))}
102
+ </div>
103
+ </CollapsibleSection>
104
+ </div>
105
+ ) : null}
106
+ </div>
107
+ </div>
108
+ );
109
+ }
@@ -0,0 +1,6 @@
1
+ import React from "react";
2
+ import { Loader2 } from "lucide-react";
3
+
4
+ export function Spinner() {
5
+ return <Loader2 className="w-4 h-4 animate-spin" />;
6
+ }
@@ -0,0 +1,16 @@
1
+ export const METHOD_COLORS: Record<string, string> = {
2
+ GET: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
3
+ POST: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
4
+ PUT: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
5
+ PATCH: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
6
+ DELETE: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
7
+ };
8
+
9
+ export const AUTH_STORAGE_PREFIX = "opendocs_playground_auth_";
10
+
11
+ export const DEFAULT_AUTH_STATE = {
12
+ type: "none" as const,
13
+ bearer: { token: "" },
14
+ apikey: { name: "", value: "", location: "header" as const },
15
+ basic: { username: "", password: "" },
16
+ };