@marlinjai/clearify 1.5.2 → 1.6.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.
@@ -1,14 +1,104 @@
1
- import { OpenAPI } from './OpenAPI.js';
2
1
  // @ts-expect-error virtual module
3
2
  import config from 'virtual:clearify/config';
4
3
  // @ts-expect-error virtual module
5
4
  import openapiSpec from 'virtual:clearify/openapi-spec';
6
5
 
6
+ import React, { useEffect } from 'react';
7
+ import { ApiHeader } from './openapi/ApiHeader.js';
8
+ import { TagGroup } from './openapi/TagGroup.js';
9
+
10
+ const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const;
11
+
7
12
  export default function OpenAPIPage() {
8
- const basePath = config.openapi?.basePath ?? '/api';
13
+ const spec = openapiSpec as Record<string, any> | null;
14
+
15
+ // Scroll to hash anchor on mount and hash change
16
+ useEffect(() => {
17
+ const scrollToHash = () => {
18
+ const hash = window.location.hash.slice(1); // remove #
19
+ if (!hash) return;
20
+ // Small delay to ensure DOM is rendered
21
+ setTimeout(() => {
22
+ const el = document.getElementById(hash);
23
+ if (el) {
24
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
25
+ }
26
+ }, 100);
27
+ };
28
+
29
+ scrollToHash();
30
+ window.addEventListener('hashchange', scrollToHash);
31
+ return () => window.removeEventListener('hashchange', scrollToHash);
32
+ }, []);
33
+
34
+ if (!spec) {
35
+ return (
36
+ <div style={{
37
+ border: '1px solid var(--clearify-border)',
38
+ borderRadius: 'var(--clearify-radius)',
39
+ padding: '2rem',
40
+ textAlign: 'center',
41
+ color: 'var(--clearify-text-secondary)',
42
+ fontSize: '0.875rem',
43
+ }}>
44
+ No OpenAPI spec provided. Set <code style={{ fontFamily: 'var(--font-mono)' }}>openapi.spec</code> in your Clearify config.
45
+ </div>
46
+ );
47
+ }
48
+
49
+ // Determine base URL from spec or config
50
+ const baseUrl = spec.servers?.[0]?.url ?? 'http://localhost:3000';
51
+
52
+ // Group operations by tag
53
+ const paths = spec.paths ?? {};
54
+ const tagMap = new Map<string, { tag: string; description?: string; operations: Array<{ method: string; path: string; operation: Record<string, any> }> }>();
55
+ const tagDescriptions = new Map<string, string>();
56
+
57
+ if (Array.isArray(spec.tags)) {
58
+ for (const t of spec.tags) {
59
+ if (t.name && t.description) tagDescriptions.set(t.name, t.description);
60
+ }
61
+ }
62
+
63
+ for (const [pathStr, pathItem] of Object.entries(paths)) {
64
+ if (!pathItem || typeof pathItem !== 'object') continue;
65
+
66
+ for (const method of HTTP_METHODS) {
67
+ const operation = (pathItem as Record<string, any>)[method];
68
+ if (!operation || typeof operation !== 'object') continue;
69
+
70
+ const tags: string[] = Array.isArray(operation.tags) && operation.tags.length > 0
71
+ ? [operation.tags[0]]
72
+ : ['Default'];
73
+
74
+ for (const tag of tags) {
75
+ if (!tagMap.has(tag)) {
76
+ tagMap.set(tag, { tag, description: tagDescriptions.get(tag), operations: [] });
77
+ }
78
+ tagMap.get(tag)!.operations.push({ method: method.toUpperCase(), path: pathStr, operation });
79
+ }
80
+ }
81
+ }
82
+
83
+ // Sort: "Default" last, others alphabetical
84
+ const tagGroups = [...tagMap.values()].sort((a, b) => {
85
+ if (a.tag === 'Default') return 1;
86
+ if (b.tag === 'Default') return -1;
87
+ return a.tag.localeCompare(b.tag);
88
+ });
89
+
9
90
  return (
10
- <div className="clearify-openapi-page" style={{ margin: '0 -1.5rem', maxWidth: 'none' }}>
11
- <OpenAPI spec={openapiSpec} hideSidebar={true} pathRouting={{ basePath }} />
91
+ <div className="clearify-openapi-page" style={{ maxWidth: 'none' }}>
92
+ <ApiHeader spec={spec} />
93
+ {tagGroups.map((group) => (
94
+ <TagGroup
95
+ key={group.tag}
96
+ tag={group.tag}
97
+ description={group.description}
98
+ operations={group.operations}
99
+ baseUrl={baseUrl}
100
+ />
101
+ ))}
12
102
  </div>
13
103
  );
14
104
  }
@@ -0,0 +1,119 @@
1
+ import React from 'react';
2
+
3
+ interface ApiHeaderProps {
4
+ spec: Record<string, any>;
5
+ }
6
+
7
+ export function ApiHeader({ spec }: ApiHeaderProps) {
8
+ const info = spec.info ?? {};
9
+ const servers = spec.servers ?? [];
10
+ const securitySchemes = spec.components?.securitySchemes ?? {};
11
+ const schemeEntries = Object.entries(securitySchemes);
12
+
13
+ return (
14
+ <div style={{
15
+ marginBottom: '2rem',
16
+ paddingBottom: '1.5rem',
17
+ borderBottom: '1px solid var(--clearify-border)',
18
+ }}>
19
+ {/* Title + Version */}
20
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
21
+ <h1 style={{
22
+ fontSize: '1.75rem',
23
+ fontWeight: 800,
24
+ color: 'var(--clearify-text)',
25
+ letterSpacing: '-0.035em',
26
+ lineHeight: 1.2,
27
+ margin: 0,
28
+ }}>
29
+ {info.title ?? 'API Reference'}
30
+ </h1>
31
+ {info.version && (
32
+ <span style={{
33
+ fontFamily: 'var(--font-mono)',
34
+ fontSize: '0.75rem',
35
+ fontWeight: 600,
36
+ color: 'var(--clearify-primary)',
37
+ background: 'var(--clearify-primary-soft)',
38
+ padding: '0.15rem 0.5rem',
39
+ borderRadius: '4px',
40
+ }}>
41
+ v{info.version}
42
+ </span>
43
+ )}
44
+ <span style={{
45
+ fontFamily: 'var(--font-mono)',
46
+ fontSize: '0.6875rem',
47
+ color: 'var(--clearify-text-tertiary)',
48
+ }}>
49
+ OAS {spec.openapi ?? '3.0'}
50
+ </span>
51
+ </div>
52
+
53
+ {/* Description */}
54
+ {info.description && (
55
+ <p style={{
56
+ fontSize: '0.9375rem',
57
+ color: 'var(--clearify-text-secondary)',
58
+ marginTop: '0.5rem',
59
+ lineHeight: 1.6,
60
+ maxWidth: '60ch',
61
+ }}>
62
+ {info.description}
63
+ </p>
64
+ )}
65
+
66
+ {/* Server + Auth row */}
67
+ <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '0.875rem' }}>
68
+ {/* Server URL */}
69
+ {servers.length > 0 && (
70
+ <div style={{
71
+ display: 'flex',
72
+ alignItems: 'center',
73
+ gap: '0.375rem',
74
+ fontSize: '0.8125rem',
75
+ }}>
76
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--clearify-text-tertiary)' }}>
77
+ <rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
78
+ <rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
79
+ <line x1="6" y1="6" x2="6.01" y2="6" />
80
+ <line x1="6" y1="18" x2="6.01" y2="18" />
81
+ </svg>
82
+ <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--clearify-text-secondary)' }}>
83
+ {servers[0].url}
84
+ </span>
85
+ </div>
86
+ )}
87
+
88
+ {/* Auth schemes */}
89
+ {schemeEntries.map(([name, scheme]: [string, any]) => (
90
+ <div
91
+ key={name}
92
+ style={{
93
+ display: 'flex',
94
+ alignItems: 'center',
95
+ gap: '0.375rem',
96
+ fontSize: '0.8125rem',
97
+ }}
98
+ >
99
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--clearify-text-tertiary)' }}>
100
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
101
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
102
+ </svg>
103
+ <span style={{
104
+ fontFamily: 'var(--font-mono)',
105
+ fontSize: '0.75rem',
106
+ color: 'var(--clearify-text-secondary)',
107
+ background: 'var(--clearify-bg-secondary)',
108
+ padding: '0.1rem 0.4rem',
109
+ borderRadius: '4px',
110
+ border: '1px solid var(--clearify-border)',
111
+ }}>
112
+ {scheme.type === 'http' ? `${scheme.scheme} (${scheme.bearerFormat ?? 'token'})` : `${scheme.type}: ${name}`}
113
+ </span>
114
+ </div>
115
+ ))}
116
+ </div>
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,100 @@
1
+ import React, { useState, useMemo, useRef } from 'react';
2
+ import { generateSnippets } from './generate-snippets.js';
3
+
4
+ interface CodeExamplesProps {
5
+ method: string;
6
+ path: string;
7
+ baseUrl: string;
8
+ parameters?: any[];
9
+ requestBody?: Record<string, any>;
10
+ security?: Array<Record<string, string[]>>;
11
+ }
12
+
13
+ export function CodeExamples({ method, path, baseUrl, parameters, requestBody, security }: CodeExamplesProps) {
14
+ const [activeTab, setActiveTab] = useState(0);
15
+ const [copied, setCopied] = useState(false);
16
+ const codeRef = useRef<HTMLPreElement>(null);
17
+
18
+ const snippets = useMemo(
19
+ () => generateSnippets({ method, path, baseUrl, parameters, requestBody, security }),
20
+ [method, path, baseUrl, parameters, requestBody, security]
21
+ );
22
+
23
+ const handleCopy = async () => {
24
+ const text = codeRef.current?.textContent ?? snippets[activeTab]?.code ?? '';
25
+ await navigator.clipboard.writeText(text);
26
+ setCopied(true);
27
+ setTimeout(() => setCopied(false), 2000);
28
+ };
29
+
30
+ return (
31
+ <div style={{
32
+ border: '1px solid var(--clearify-border)',
33
+ borderRadius: 'var(--clearify-radius)',
34
+ overflow: 'hidden',
35
+ background: '#1e1e2e',
36
+ }}>
37
+ <div style={{
38
+ display: 'flex',
39
+ alignItems: 'center',
40
+ justifyContent: 'space-between',
41
+ borderBottom: '1px solid rgba(255,255,255,0.08)',
42
+ padding: '0 0.5rem',
43
+ }}>
44
+ <div style={{ display: 'flex', gap: '0.125rem' }}>
45
+ {snippets.map((tab, i) => (
46
+ <button
47
+ key={tab.label}
48
+ onClick={() => setActiveTab(i)}
49
+ style={{
50
+ fontFamily: 'var(--font-mono)',
51
+ fontSize: '0.75rem',
52
+ fontWeight: i === activeTab ? 600 : 400,
53
+ padding: '0.5rem 0.625rem',
54
+ background: 'none',
55
+ border: 'none',
56
+ borderBottom: i === activeTab ? '2px solid #818cf8' : '2px solid transparent',
57
+ color: i === activeTab ? '#e2e8f0' : '#94a3b8',
58
+ cursor: 'pointer',
59
+ transition: 'color 0.15s ease',
60
+ }}
61
+ >
62
+ {tab.label}
63
+ </button>
64
+ ))}
65
+ </div>
66
+ <button
67
+ onClick={handleCopy}
68
+ style={{
69
+ background: 'none',
70
+ border: '1px solid rgba(255,255,255,0.1)',
71
+ borderRadius: '4px',
72
+ padding: '0.2rem 0.5rem',
73
+ cursor: 'pointer',
74
+ fontSize: '0.6875rem',
75
+ color: copied ? '#818cf8' : '#94a3b8',
76
+ fontFamily: 'var(--font-sans)',
77
+ transition: 'all 0.15s ease',
78
+ }}
79
+ >
80
+ {copied ? 'Copied!' : 'Copy'}
81
+ </button>
82
+ </div>
83
+ <pre
84
+ ref={codeRef}
85
+ style={{
86
+ padding: '1rem 1.25rem',
87
+ margin: 0,
88
+ fontFamily: 'var(--font-mono)',
89
+ fontSize: '0.8125rem',
90
+ lineHeight: 1.7,
91
+ color: '#e2e8f0',
92
+ overflow: 'auto',
93
+ maxHeight: '400px',
94
+ }}
95
+ >
96
+ <code>{snippets[activeTab]?.code ?? ''}</code>
97
+ </pre>
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,48 @@
1
+ import React from 'react';
2
+
3
+ const METHOD_STYLES: Record<string, { bg: string; color: string; darkColor: string }> = {
4
+ GET: { bg: 'rgba(5, 150, 105, 0.12)', color: '#059669', darkColor: '#34d399' },
5
+ POST: { bg: 'rgba(37, 99, 235, 0.12)', color: '#2563eb', darkColor: '#60a5fa' },
6
+ PUT: { bg: 'rgba(217, 119, 6, 0.12)', color: '#d97706', darkColor: '#fbbf24' },
7
+ DELETE: { bg: 'rgba(220, 38, 38, 0.12)', color: '#dc2626', darkColor: '#f87171' },
8
+ PATCH: { bg: 'rgba(124, 58, 237, 0.12)', color: '#7c3aed', darkColor: '#a78bfa' },
9
+ };
10
+
11
+ interface MethodBadgeProps {
12
+ method: string;
13
+ }
14
+
15
+ export function MethodBadge({ method }: MethodBadgeProps) {
16
+ const upper = method.toUpperCase();
17
+ const style = METHOD_STYLES[upper] ?? { bg: 'rgba(0,0,0,0.06)', color: 'inherit', darkColor: 'inherit' };
18
+
19
+ return (
20
+ <>
21
+ <span
22
+ className={`clearify-method-badge clearify-method-${upper.toLowerCase()}`}
23
+ style={{
24
+ fontFamily: 'var(--font-mono)',
25
+ fontSize: '0.6875rem',
26
+ fontWeight: 700,
27
+ letterSpacing: '0.04em',
28
+ textTransform: 'uppercase' as const,
29
+ padding: '0.15rem 0.5rem',
30
+ borderRadius: '4px',
31
+ lineHeight: 1.5,
32
+ display: 'inline-block',
33
+ background: style.bg,
34
+ color: style.color,
35
+ }}
36
+ >
37
+ {upper}
38
+ </span>
39
+ <style>{`
40
+ .dark .clearify-method-get { color: ${METHOD_STYLES.GET.darkColor} !important; }
41
+ .dark .clearify-method-post { color: ${METHOD_STYLES.POST.darkColor} !important; }
42
+ .dark .clearify-method-put { color: ${METHOD_STYLES.PUT.darkColor} !important; }
43
+ .dark .clearify-method-delete { color: ${METHOD_STYLES.DELETE.darkColor} !important; }
44
+ .dark .clearify-method-patch { color: ${METHOD_STYLES.PATCH.darkColor} !important; }
45
+ `}</style>
46
+ </>
47
+ );
48
+ }
@@ -0,0 +1,198 @@
1
+ import React from 'react';
2
+ import { MethodBadge } from './MethodBadge.js';
3
+ import { ParameterTable } from './ParameterTable.js';
4
+ import { SchemaViewer } from './SchemaViewer.js';
5
+ import { ResponseList } from './ResponseList.js';
6
+ import { CodeExamples } from './CodeExamples.js';
7
+
8
+ interface OperationCardProps {
9
+ method: string;
10
+ path: string;
11
+ operation: Record<string, any>;
12
+ baseUrl: string;
13
+ anchorId: string;
14
+ }
15
+
16
+ export function OperationCard({ method, path, operation, baseUrl, anchorId }: OperationCardProps) {
17
+ const summary = operation.summary ?? operation.description ?? `${method.toUpperCase()} ${path}`;
18
+ const description = operation.description && operation.description !== summary ? operation.description : null;
19
+ const parameters = operation.parameters ?? [];
20
+ const requestBody = operation.requestBody;
21
+ const responses = operation.responses ?? {};
22
+ const security = operation.security;
23
+ const deprecated = !!operation.deprecated;
24
+
25
+ const bodySchema = requestBody?.content?.['application/json']?.schema;
26
+
27
+ return (
28
+ <div
29
+ id={anchorId}
30
+ style={{
31
+ border: '1px solid var(--clearify-border)',
32
+ borderRadius: 'var(--clearify-radius)',
33
+ overflow: 'hidden',
34
+ marginBottom: '1rem',
35
+ transition: 'border-color 0.2s ease, box-shadow 0.2s ease',
36
+ }}
37
+ className="clearify-operation-card"
38
+ >
39
+ {/* Two-column layout */}
40
+ <div className="clearify-operation-layout" style={{
41
+ display: 'grid',
42
+ gridTemplateColumns: '1fr 1fr',
43
+ gap: 0,
44
+ }}>
45
+ {/* Left column — documentation */}
46
+ <div style={{
47
+ padding: '1.25rem 1.5rem',
48
+ borderRight: '1px solid var(--clearify-border)',
49
+ }}>
50
+ {/* Method + Path header */}
51
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', flexWrap: 'wrap' }}>
52
+ <MethodBadge method={method} />
53
+ <span style={{
54
+ fontFamily: 'var(--font-mono)',
55
+ fontSize: '0.875rem',
56
+ fontWeight: 500,
57
+ color: deprecated ? 'var(--clearify-text-tertiary)' : 'var(--clearify-text)',
58
+ textDecoration: deprecated ? 'line-through' : undefined,
59
+ letterSpacing: '-0.01em',
60
+ }}>
61
+ {path}
62
+ </span>
63
+ {deprecated && (
64
+ <span style={{
65
+ fontSize: '0.6875rem',
66
+ fontWeight: 600,
67
+ textTransform: 'uppercase' as const,
68
+ color: '#d97706',
69
+ background: 'rgba(217, 119, 6, 0.1)',
70
+ padding: '0.1rem 0.4rem',
71
+ borderRadius: '4px',
72
+ }}>
73
+ Deprecated
74
+ </span>
75
+ )}
76
+ {security && security.length > 0 && (
77
+ <span style={{
78
+ fontSize: '0.6875rem',
79
+ color: 'var(--clearify-text-tertiary)',
80
+ display: 'flex',
81
+ alignItems: 'center',
82
+ gap: '0.25rem',
83
+ }}>
84
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
85
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
86
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
87
+ </svg>
88
+ Auth
89
+ </span>
90
+ )}
91
+ </div>
92
+
93
+ {/* Summary */}
94
+ <div style={{
95
+ fontSize: '0.9375rem',
96
+ fontWeight: 600,
97
+ color: 'var(--clearify-text)',
98
+ marginTop: '0.625rem',
99
+ letterSpacing: '-0.01em',
100
+ }}>
101
+ {summary}
102
+ </div>
103
+
104
+ {/* Description */}
105
+ {description && (
106
+ <div style={{
107
+ fontSize: '0.8125rem',
108
+ color: 'var(--clearify-text-secondary)',
109
+ marginTop: '0.375rem',
110
+ lineHeight: 1.6,
111
+ }}>
112
+ {description}
113
+ </div>
114
+ )}
115
+
116
+ {/* Parameters */}
117
+ {parameters.length > 0 && <ParameterTable parameters={parameters} />}
118
+
119
+ {/* Request Body */}
120
+ {bodySchema && (
121
+ <div style={{ marginTop: '0.75rem' }}>
122
+ <div style={{
123
+ fontSize: '0.6875rem',
124
+ fontWeight: 600,
125
+ textTransform: 'uppercase' as const,
126
+ letterSpacing: '0.05em',
127
+ color: 'var(--clearify-text-tertiary)',
128
+ marginBottom: '0.375rem',
129
+ }}>
130
+ Request Body
131
+ {requestBody?.required && (
132
+ <span className="clearify-required-badge" style={{
133
+ marginLeft: '0.5rem',
134
+ color: '#d97706',
135
+ }}>
136
+ required
137
+ </span>
138
+ )}
139
+ </div>
140
+ <div style={{
141
+ border: '1px solid var(--clearify-border)',
142
+ borderRadius: 'var(--clearify-radius)',
143
+ padding: '0.625rem 0.875rem',
144
+ }}>
145
+ <SchemaViewer schema={bodySchema} />
146
+ </div>
147
+ </div>
148
+ )}
149
+
150
+ {/* Responses */}
151
+ <ResponseList responses={responses} />
152
+ </div>
153
+
154
+ {/* Right column — code examples */}
155
+ <div style={{
156
+ padding: '1.25rem 1.5rem',
157
+ background: 'var(--clearify-bg-secondary)',
158
+ }}>
159
+ <div style={{
160
+ fontSize: '0.6875rem',
161
+ fontWeight: 600,
162
+ textTransform: 'uppercase' as const,
163
+ letterSpacing: '0.05em',
164
+ color: 'var(--clearify-text-tertiary)',
165
+ marginBottom: '0.625rem',
166
+ }}>
167
+ Examples
168
+ </div>
169
+ <CodeExamples
170
+ method={method}
171
+ path={path}
172
+ baseUrl={baseUrl}
173
+ parameters={parameters}
174
+ requestBody={requestBody}
175
+ security={security}
176
+ />
177
+ </div>
178
+ </div>
179
+
180
+ <style>{`
181
+ .clearify-operation-card:hover {
182
+ border-color: var(--clearify-border-strong) !important;
183
+ box-shadow: var(--clearify-shadow-sm);
184
+ }
185
+ @media (max-width: 900px) {
186
+ .clearify-operation-layout {
187
+ grid-template-columns: 1fr !important;
188
+ }
189
+ .clearify-operation-layout > div:first-child {
190
+ border-right: none !important;
191
+ border-bottom: 1px solid var(--clearify-border) !important;
192
+ }
193
+ }
194
+ .dark .clearify-required-badge { color: #fbbf24 !important; }
195
+ `}</style>
196
+ </div>
197
+ );
198
+ }