froggy-docs 1.1.1 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +21 -0
- package/.github/workflows/froggy-docs.yml +59 -0
- package/ABOUT.md +99 -0
- package/MILESTONE.md +29 -17
- package/bin/froggy_docs.dart +3 -45
- package/frontend/lib/app.dart +487 -280
- package/frontend/lib/components/file_field.dart +75 -0
- package/frontend/web/app.js +224 -67
- package/frontend/web/froggy_docs.json +288 -4
- package/lib/froggy_docs.dart +1 -0
- package/lib/src/cli_runner.dart +123 -0
- package/lib/src/parser_engine.dart +351 -22
- package/lib/src/watcher_engine.dart +46 -14
- package/lib/src/web_server.dart +40 -17
- package/package.js +0 -0
- package/package.json +4 -4
- package/bin/froggy-docs +0 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import 'dart:js_interop';
|
|
2
|
+
|
|
3
|
+
import 'package:jaspr/dom.dart';
|
|
4
|
+
import 'package:jaspr/jaspr.dart';
|
|
5
|
+
import 'package:web/web.dart' as web;
|
|
6
|
+
|
|
7
|
+
/// A modular form field component that renders either a text input or a file
|
|
8
|
+
/// input depending on [isFile]. File bytes are surfaced to the parent via
|
|
9
|
+
/// [onFileSelected]; text changes via [onTextChanged].
|
|
10
|
+
class FileField extends StatelessComponent {
|
|
11
|
+
const FileField({
|
|
12
|
+
required this.endpointKey,
|
|
13
|
+
required this.fieldName,
|
|
14
|
+
required this.description,
|
|
15
|
+
required this.isFile,
|
|
16
|
+
required this.fieldType,
|
|
17
|
+
this.savedValue = '',
|
|
18
|
+
required this.onTextChanged,
|
|
19
|
+
required this.onFileSelected,
|
|
20
|
+
super.key,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
final String endpointKey;
|
|
24
|
+
final String fieldName;
|
|
25
|
+
final String description;
|
|
26
|
+
final bool isFile;
|
|
27
|
+
final String fieldType;
|
|
28
|
+
final String savedValue;
|
|
29
|
+
final void Function(String value) onTextChanged;
|
|
30
|
+
final void Function(List<int> bytes, String name) onFileSelected;
|
|
31
|
+
|
|
32
|
+
@override
|
|
33
|
+
Component build(BuildContext context) {
|
|
34
|
+
return div(
|
|
35
|
+
[
|
|
36
|
+
label([Component.text('$fieldName (${isFile ? 'file' : fieldType})')]),
|
|
37
|
+
if (description.isNotEmpty)
|
|
38
|
+
span([Component.text(description)], classes: 'field-desc'),
|
|
39
|
+
if (isFile)
|
|
40
|
+
input<List<web.File>>(
|
|
41
|
+
type: InputType.file,
|
|
42
|
+
classes: 'form-input file-input',
|
|
43
|
+
onChange: (files) {
|
|
44
|
+
if (files.isNotEmpty) {
|
|
45
|
+
final file = files.first;
|
|
46
|
+
final fileName = file.name;
|
|
47
|
+
final reader = web.FileReader();
|
|
48
|
+
reader.addEventListener(
|
|
49
|
+
'load',
|
|
50
|
+
((JSAny? _) {
|
|
51
|
+
final result = reader.result;
|
|
52
|
+
if (result != null) {
|
|
53
|
+
onFileSelected(
|
|
54
|
+
(result as JSArrayBuffer).toDart.asUint8List(),
|
|
55
|
+
fileName,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}).toJS,
|
|
59
|
+
);
|
|
60
|
+
reader.readAsArrayBuffer(file);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
else
|
|
65
|
+
input<String>(
|
|
66
|
+
type: InputType.text,
|
|
67
|
+
classes: 'form-input',
|
|
68
|
+
value: savedValue,
|
|
69
|
+
onInput: onTextChanged,
|
|
70
|
+
),
|
|
71
|
+
],
|
|
72
|
+
classes: 'form-field',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
package/frontend/web/app.js
CHANGED
|
@@ -6,6 +6,8 @@ const responses = {};
|
|
|
6
6
|
const loading = {};
|
|
7
7
|
const requestBodyValues = {};
|
|
8
8
|
const expandedEndpoints = {};
|
|
9
|
+
const expandedQuery = {};
|
|
10
|
+
const expandedHeaders = {};
|
|
9
11
|
|
|
10
12
|
const methodColors = {
|
|
11
13
|
GET: '#61affe',
|
|
@@ -19,7 +21,11 @@ async function loadData() {
|
|
|
19
21
|
try {
|
|
20
22
|
const resp = await fetch('/froggy_docs.json');
|
|
21
23
|
if (resp.ok) {
|
|
22
|
-
|
|
24
|
+
const newData = await resp.json();
|
|
25
|
+
if (apiData && JSON.stringify(apiData) !== JSON.stringify(newData)) {
|
|
26
|
+
showHotReloadToast();
|
|
27
|
+
}
|
|
28
|
+
apiData = newData;
|
|
23
29
|
render();
|
|
24
30
|
}
|
|
25
31
|
} catch (e) {
|
|
@@ -27,6 +33,20 @@ async function loadData() {
|
|
|
27
33
|
}
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
function showHotReloadToast() {
|
|
37
|
+
let toast = document.getElementById('hot-reload-toast');
|
|
38
|
+
if (!toast) {
|
|
39
|
+
toast = document.createElement('div');
|
|
40
|
+
toast.id = 'hot-reload-toast';
|
|
41
|
+
toast.className = 'toast';
|
|
42
|
+
document.body.appendChild(toast);
|
|
43
|
+
}
|
|
44
|
+
toast.textContent = '🔄 Spec hot-reloaded!';
|
|
45
|
+
toast.style.display = 'block';
|
|
46
|
+
clearTimeout(toast._timer);
|
|
47
|
+
toast._timer = setTimeout(() => { toast.style.display = 'none'; }, 3000);
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
function getEndpointsByTag() {
|
|
31
51
|
const byTag = {};
|
|
32
52
|
const paths = apiData?.paths || {};
|
|
@@ -59,10 +79,10 @@ function renderSidebar() {
|
|
|
59
79
|
let html = '';
|
|
60
80
|
for (const [tag, endpoints] of filtered) {
|
|
61
81
|
const tagMatchesSearch = searchQuery === '' || tag.toLowerCase().includes(searchQuery.toLowerCase());
|
|
62
|
-
const tagFiltered =
|
|
63
|
-
? endpoints
|
|
82
|
+
const tagFiltered = tagMatchesSearch
|
|
83
|
+
? endpoints
|
|
64
84
|
: endpoints.filter(ep => ep.path.toLowerCase().includes(searchQuery.toLowerCase()));
|
|
65
|
-
|
|
85
|
+
|
|
66
86
|
if (searchQuery !== '' && tagFiltered.length === 0) continue;
|
|
67
87
|
|
|
68
88
|
html += `
|
|
@@ -98,31 +118,134 @@ function renderApiList() {
|
|
|
98
118
|
document.getElementById('apiList').innerHTML = html;
|
|
99
119
|
}
|
|
100
120
|
|
|
121
|
+
function getBodyProps(spec) {
|
|
122
|
+
const jsonProps = spec.requestBody?.content?.['application/json']?.schema?.properties;
|
|
123
|
+
const multipartProps = spec.requestBody?.content?.['multipart/form-data']?.schema?.properties;
|
|
124
|
+
return { props: jsonProps || multipartProps, hasFiles: !!multipartProps };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function renderFormField(key, name, prop) {
|
|
128
|
+
const isFile = prop.format === 'binary';
|
|
129
|
+
const savedValue = (requestBodyValues[key] || {})[name] || '';
|
|
130
|
+
return `
|
|
131
|
+
<div class="form-field">
|
|
132
|
+
<label>${name} (${isFile ? 'file' : (prop.type || 'string')})</label>
|
|
133
|
+
${prop.description ? `<span class="field-desc">${prop.description}</span>` : ''}
|
|
134
|
+
${isFile
|
|
135
|
+
? `<input type="file" class="form-input file-input" id="file-${key}-${name}">`
|
|
136
|
+
: `<input type="text" class="form-input"
|
|
137
|
+
placeholder="${prop.default || ''}"
|
|
138
|
+
value="${savedValue}"
|
|
139
|
+
oninput="saveBodyValue('${key}', '${name}', this.value)">`
|
|
140
|
+
}
|
|
141
|
+
</div>
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderQuerySection(key, params) {
|
|
146
|
+
if (!params || params.length === 0) return '';
|
|
147
|
+
const isExpanded = expandedQuery[key] || false;
|
|
148
|
+
return `
|
|
149
|
+
<div class="query-section">
|
|
150
|
+
<button class="collapsible-btn" onclick="toggleQuery('${key}')">
|
|
151
|
+
${isExpanded ? '▲' : '▼'} Query Params
|
|
152
|
+
</button>
|
|
153
|
+
${isExpanded ? `
|
|
154
|
+
<div class="params-form">
|
|
155
|
+
${params.map(qp => {
|
|
156
|
+
const savedVal = (requestBodyValues[`query-${key}`] || {})[qp.name] || '';
|
|
157
|
+
const defaultVal = qp.schema?.default ?? '';
|
|
158
|
+
return `
|
|
159
|
+
<div class="form-field">
|
|
160
|
+
<label>${qp.name} (${qp.schema?.type || 'string'})</label>
|
|
161
|
+
${qp.description ? `<span class="field-desc">${qp.description}</span>` : ''}
|
|
162
|
+
<input type="text" class="form-input"
|
|
163
|
+
placeholder="${defaultVal}"
|
|
164
|
+
value="${savedVal}"
|
|
165
|
+
oninput="saveQueryValue('${key}', '${qp.name}', this.value)">
|
|
166
|
+
</div>
|
|
167
|
+
`;
|
|
168
|
+
}).join('')}
|
|
169
|
+
</div>
|
|
170
|
+
` : ''}
|
|
171
|
+
</div>
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderHeadersSection(key, params) {
|
|
176
|
+
if (!params || params.length === 0) return '';
|
|
177
|
+
const isExpanded = expandedHeaders[key] || false;
|
|
178
|
+
return `
|
|
179
|
+
<div class="headers-section">
|
|
180
|
+
<button class="collapsible-btn" onclick="toggleHeaders('${key}')">
|
|
181
|
+
${isExpanded ? '▲' : '▼'} Headers
|
|
182
|
+
</button>
|
|
183
|
+
${isExpanded ? `
|
|
184
|
+
<div class="params-form">
|
|
185
|
+
${params.map(hp => {
|
|
186
|
+
const savedVal = (requestBodyValues[`header-${key}`] || {})[hp.name] || '';
|
|
187
|
+
return `
|
|
188
|
+
<div class="form-field">
|
|
189
|
+
<label>${hp.name} (${hp.schema?.type || 'string'})</label>
|
|
190
|
+
${hp.description ? `<span class="field-desc">${hp.description}</span>` : ''}
|
|
191
|
+
<input type="text" class="form-input"
|
|
192
|
+
value="${savedVal}"
|
|
193
|
+
oninput="saveHeaderValue('${key}', '${hp.name}', this.value)">
|
|
194
|
+
</div>
|
|
195
|
+
`;
|
|
196
|
+
}).join('')}
|
|
197
|
+
</div>
|
|
198
|
+
` : ''}
|
|
199
|
+
</div>
|
|
200
|
+
`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function renderResponseSchemas(responses) {
|
|
204
|
+
if (!responses) return '';
|
|
205
|
+
const entries = Object.entries(responses);
|
|
206
|
+
if (entries.length === 0) return '';
|
|
207
|
+
return `
|
|
208
|
+
<div class="response-schemas">
|
|
209
|
+
<h3 class="section-title">Responses</h3>
|
|
210
|
+
${entries.map(([code, resp]) => {
|
|
211
|
+
const content = resp.content?.['application/json'];
|
|
212
|
+
const schemaType = content?.schema?.type || 'object';
|
|
213
|
+
const example = content?.example;
|
|
214
|
+
return `
|
|
215
|
+
<div class="response-schema">
|
|
216
|
+
<div class="response-code">${code} - ${resp.description || ''}</div>
|
|
217
|
+
${content ? `<div class="schema-type">Type: ${schemaType}</div>` : ''}
|
|
218
|
+
${example != null ? `<pre class="schema-example">${JSON.stringify(example, null, 2)}</pre>` : ''}
|
|
219
|
+
</div>
|
|
220
|
+
`;
|
|
221
|
+
}).join('')}
|
|
222
|
+
</div>
|
|
223
|
+
`;
|
|
224
|
+
}
|
|
225
|
+
|
|
101
226
|
function renderEndpoint(path, method, spec) {
|
|
102
227
|
const key = `${method}-${path}`;
|
|
103
|
-
const props = spec
|
|
228
|
+
const { props, hasFiles } = getBodyProps(spec);
|
|
104
229
|
const hasAuth = spec.security != null;
|
|
105
230
|
const isExpanded = expandedEndpoints[key] || false;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
`;
|
|
125
|
-
}
|
|
231
|
+
|
|
232
|
+
const queryParams = (spec.parameters || []).filter(p => p.in === 'query');
|
|
233
|
+
const headerParams = (spec.parameters || []).filter(p => p.in === 'header');
|
|
234
|
+
|
|
235
|
+
const formHtml = props && Object.keys(props).length > 0 ? `
|
|
236
|
+
<button class="try-it-out-btn ${isExpanded ? '' : 'secondary'}" onclick="toggleBody('${key}')">
|
|
237
|
+
${isExpanded ? 'Hide Request Body' : 'Show Request Body'}
|
|
238
|
+
</button>
|
|
239
|
+
${isExpanded ? `
|
|
240
|
+
<h3 class="section-title">${hasFiles ? 'Multipart Form Data' : 'Request Body'}</h3>
|
|
241
|
+
<div class="request-body-form">
|
|
242
|
+
${Object.entries(props).map(([name, prop]) => renderFormField(key, name, prop)).join('')}
|
|
243
|
+
</div>
|
|
244
|
+
` : ''}
|
|
245
|
+
` : '';
|
|
246
|
+
|
|
247
|
+
const propsJson = props ? JSON.stringify(props).replace(/"/g, '"') : 'null';
|
|
248
|
+
const hasFilesStr = hasFiles ? 'true' : 'false';
|
|
126
249
|
|
|
127
250
|
return `
|
|
128
251
|
<section class="api-section" id="${method.toUpperCase()}-${path}">
|
|
@@ -132,83 +255,117 @@ function renderEndpoint(path, method, spec) {
|
|
|
132
255
|
${hasAuth ? '<span class="auth-badge">🔒 Auth</span>' : ''}
|
|
133
256
|
</div>
|
|
134
257
|
<p class="api-description">${spec.summary || 'No description'}</p>
|
|
135
|
-
|
|
136
|
-
${props && Object.keys(props).length > 0 ? `
|
|
137
|
-
<button class="try-it-out-btn ${isExpanded ? '' : 'secondary'}" onclick="toggleBody('${key}')">
|
|
138
|
-
${isExpanded ? 'Hide Request Body' : 'Show Request Body'}
|
|
139
|
-
</button>
|
|
140
|
-
${isExpanded ? `
|
|
141
|
-
<h3 class="section-title">Request Body</h3>
|
|
142
|
-
<div class="request-body-form">
|
|
143
|
-
${Object.entries(props).map(([name, prop]) => `
|
|
144
|
-
<div class="form-field">
|
|
145
|
-
<label>${name} (${prop.type || 'string'})</label>
|
|
146
|
-
${prop.description ? `<span class="field-desc">${prop.description}</span>` : ''}
|
|
147
|
-
<input type="text" class="form-input" placeholder="${prop.default || ''}"
|
|
148
|
-
value="${savedValues[name] || ''}"
|
|
149
|
-
oninput="saveBodyValue('${key}', '${name}', this.value)">
|
|
150
|
-
</div>
|
|
151
|
-
`).join('')}
|
|
152
|
-
</div>
|
|
153
|
-
` : ''}
|
|
154
|
-
` : ''}
|
|
155
258
|
|
|
156
|
-
|
|
259
|
+
${formHtml}
|
|
260
|
+
${renderQuerySection(key, queryParams)}
|
|
261
|
+
${renderHeadersSection(key, headerParams)}
|
|
262
|
+
${renderResponseSchemas(spec.responses)}
|
|
263
|
+
|
|
264
|
+
<button class="try-it-out-btn" onclick="executeRequest('${method}', '${path}', ${hasAuth}, ${propsJson}, ${hasFilesStr})">
|
|
157
265
|
${loading[key] ? 'Loading...' : 'Try It Out'}
|
|
158
266
|
</button>
|
|
159
267
|
|
|
160
268
|
${responses[key] ? `
|
|
161
269
|
<div class="response-section">
|
|
162
270
|
<h4>Response:</h4>
|
|
163
|
-
<pre class="response-body">${responses[key]}</pre>
|
|
271
|
+
<pre class="response-body">${escapeHtml(responses[key])}</pre>
|
|
164
272
|
</div>
|
|
165
273
|
` : ''}
|
|
166
|
-
|
|
167
|
-
${paramsHtml}
|
|
168
274
|
</section>
|
|
169
275
|
`;
|
|
170
276
|
}
|
|
171
277
|
|
|
278
|
+
function escapeHtml(str) {
|
|
279
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
280
|
+
}
|
|
281
|
+
|
|
172
282
|
function toggleBody(key) {
|
|
173
283
|
expandedEndpoints[key] = !expandedEndpoints[key];
|
|
174
284
|
renderApiList();
|
|
175
285
|
}
|
|
176
286
|
|
|
287
|
+
function toggleQuery(key) {
|
|
288
|
+
expandedQuery[key] = !expandedQuery[key];
|
|
289
|
+
renderApiList();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function toggleHeaders(key) {
|
|
293
|
+
expandedHeaders[key] = !expandedHeaders[key];
|
|
294
|
+
renderApiList();
|
|
295
|
+
}
|
|
296
|
+
|
|
177
297
|
function saveBodyValue(key, field, value) {
|
|
178
298
|
if (!requestBodyValues[key]) requestBodyValues[key] = {};
|
|
179
299
|
requestBodyValues[key][field] = value;
|
|
180
300
|
}
|
|
181
301
|
|
|
182
|
-
|
|
302
|
+
function saveQueryValue(key, field, value) {
|
|
303
|
+
const qKey = `query-${key}`;
|
|
304
|
+
if (!requestBodyValues[qKey]) requestBodyValues[qKey] = {};
|
|
305
|
+
requestBodyValues[qKey][field] = value;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function saveHeaderValue(key, field, value) {
|
|
309
|
+
const hKey = `header-${key}`;
|
|
310
|
+
if (!requestBodyValues[hKey]) requestBodyValues[hKey] = {};
|
|
311
|
+
requestBodyValues[hKey][field] = value;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function executeRequest(method, path, hasAuth, props, hasFiles) {
|
|
183
315
|
const key = `${method}-${path}`;
|
|
184
316
|
loading[key] = true;
|
|
185
317
|
renderApiList();
|
|
186
318
|
|
|
187
319
|
try {
|
|
188
|
-
const headers = {
|
|
320
|
+
const headers = {};
|
|
189
321
|
if (authHeader) headers['Authorization'] = authHeader;
|
|
190
322
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
323
|
+
// Add saved custom headers
|
|
324
|
+
const savedHeaders = requestBodyValues[`header-${key}`] || {};
|
|
325
|
+
for (const [name, val] of Object.entries(savedHeaders)) {
|
|
326
|
+
if (val) headers[name] = val;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Build URL with query params
|
|
330
|
+
let url = `${window.location.origin}${path}`;
|
|
331
|
+
const savedQuery = requestBodyValues[`query-${key}`] || {};
|
|
332
|
+
const queryParts = Object.entries(savedQuery).filter(([, v]) => v);
|
|
333
|
+
if (queryParts.length > 0) {
|
|
334
|
+
url += '?' + queryParts.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
|
|
200
335
|
}
|
|
201
336
|
|
|
202
337
|
let resp;
|
|
203
|
-
const url = `${window.location.origin}${path}`;
|
|
204
|
-
const options = { method, headers };
|
|
205
338
|
|
|
206
|
-
if (
|
|
207
|
-
|
|
339
|
+
if (hasFiles && props) {
|
|
340
|
+
// Multipart: use FormData to carry both text fields and file inputs
|
|
341
|
+
const formData = new FormData();
|
|
342
|
+
for (const [name, prop] of Object.entries(props)) {
|
|
343
|
+
if (prop.format === 'binary') {
|
|
344
|
+
const fileInput = document.getElementById(`file-${key}-${name}`);
|
|
345
|
+
if (fileInput && fileInput.files && fileInput.files[0]) {
|
|
346
|
+
formData.append(name, fileInput.files[0]);
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
const val = (requestBodyValues[key] || {})[name] || prop.default || '';
|
|
350
|
+
if (val) formData.append(name, val);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
resp = await fetch(url, { method, headers, body: formData });
|
|
354
|
+
} else {
|
|
355
|
+
headers['Content-Type'] = 'application/json';
|
|
356
|
+
let body;
|
|
357
|
+
if (props && method !== 'GET' && method !== 'HEAD') {
|
|
358
|
+
const bodyData = {};
|
|
359
|
+
const savedValues = requestBodyValues[key] || {};
|
|
360
|
+
for (const [name, prop] of Object.entries(props)) {
|
|
361
|
+
const value = savedValues[name] || prop.default || '';
|
|
362
|
+
if (value) bodyData[name] = prop.type === 'number' ? Number(value) : value;
|
|
363
|
+
}
|
|
364
|
+
body = JSON.stringify(bodyData);
|
|
365
|
+
}
|
|
366
|
+
resp = await fetch(url, { method, headers, body });
|
|
208
367
|
}
|
|
209
368
|
|
|
210
|
-
resp = await fetch(url, options);
|
|
211
|
-
|
|
212
369
|
let responseText;
|
|
213
370
|
try {
|
|
214
371
|
const json = await resp.json();
|
|
@@ -248,4 +405,4 @@ if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
|
248
405
|
}
|
|
249
406
|
|
|
250
407
|
loadData();
|
|
251
|
-
setInterval(loadData, 3000);
|
|
408
|
+
setInterval(loadData, 3000);
|