millas 0.2.13 → 0.2.14

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 (88) hide show
  1. package/package.json +6 -3
  2. package/src/admin/Admin.js +107 -1027
  3. package/src/admin/AdminAuth.js +1 -1
  4. package/src/admin/ViewContext.js +1 -1
  5. package/src/admin/handlers/ActionHandler.js +103 -0
  6. package/src/admin/handlers/ApiHandler.js +113 -0
  7. package/src/admin/handlers/AuthHandler.js +76 -0
  8. package/src/admin/handlers/ExportHandler.js +70 -0
  9. package/src/admin/handlers/InlineHandler.js +71 -0
  10. package/src/admin/handlers/PageHandler.js +351 -0
  11. package/src/admin/resources/AdminResource.js +22 -1
  12. package/src/admin/static/SelectFilter2.js +34 -0
  13. package/src/admin/static/actions.js +201 -0
  14. package/src/admin/static/admin.css +7 -0
  15. package/src/admin/static/change_form.js +585 -0
  16. package/src/admin/static/core.js +128 -0
  17. package/src/admin/static/login.js +76 -0
  18. package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
  19. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
  20. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
  21. package/src/admin/static/vendor/jquery.min.js +2 -0
  22. package/src/admin/views/layouts/base.njk +30 -113
  23. package/src/admin/views/pages/detail.njk +10 -9
  24. package/src/admin/views/pages/form.njk +4 -4
  25. package/src/admin/views/pages/list.njk +11 -193
  26. package/src/admin/views/pages/login.njk +19 -64
  27. package/src/admin/views/partials/form-field.njk +1 -1
  28. package/src/admin/views/partials/form-scripts.njk +4 -478
  29. package/src/admin/views/partials/form-widget.njk +10 -10
  30. package/src/ai/AITokenBudget.js +1 -1
  31. package/src/auth/Auth.js +112 -3
  32. package/src/auth/AuthMiddleware.js +18 -15
  33. package/src/auth/Hasher.js +15 -43
  34. package/src/cli.js +3 -0
  35. package/src/commands/call.js +190 -0
  36. package/src/commands/createsuperuser.js +3 -4
  37. package/src/commands/key.js +97 -0
  38. package/src/commands/make.js +16 -2
  39. package/src/commands/new.js +16 -1
  40. package/src/commands/serve.js +5 -5
  41. package/src/console/Command.js +337 -0
  42. package/src/console/CommandLoader.js +165 -0
  43. package/src/console/index.js +6 -0
  44. package/src/container/AppInitializer.js +48 -1
  45. package/src/container/Application.js +3 -1
  46. package/src/container/HttpServer.js +0 -1
  47. package/src/container/MillasConfig.js +48 -0
  48. package/src/controller/Controller.js +13 -11
  49. package/src/core/docs.js +6 -0
  50. package/src/core/foundation.js +8 -0
  51. package/src/core/http.js +20 -10
  52. package/src/core/validation.js +58 -27
  53. package/src/docs/Docs.js +268 -0
  54. package/src/docs/DocsServiceProvider.js +80 -0
  55. package/src/docs/SchemaInferrer.js +131 -0
  56. package/src/docs/handlers/ApiHandler.js +305 -0
  57. package/src/docs/handlers/PageHandler.js +47 -0
  58. package/src/docs/index.js +13 -0
  59. package/src/docs/resources/ApiResource.js +402 -0
  60. package/src/docs/static/docs.css +723 -0
  61. package/src/docs/static/docs.js +1181 -0
  62. package/src/encryption/Encrypter.js +381 -0
  63. package/src/facades/Auth.js +5 -2
  64. package/src/facades/Crypt.js +166 -0
  65. package/src/facades/Docs.js +43 -0
  66. package/src/facades/Mail.js +1 -1
  67. package/src/http/MillasRequest.js +7 -31
  68. package/src/http/RequestContext.js +11 -7
  69. package/src/http/SecurityBootstrap.js +24 -2
  70. package/src/http/Shape.js +168 -0
  71. package/src/http/adapters/ExpressAdapter.js +9 -5
  72. package/src/middleware/CorsMiddleware.js +3 -0
  73. package/src/middleware/ThrottleMiddleware.js +10 -7
  74. package/src/orm/model/Model.js +14 -1
  75. package/src/providers/EncryptionServiceProvider.js +66 -0
  76. package/src/router/MiddlewareRegistry.js +79 -54
  77. package/src/router/Route.js +9 -4
  78. package/src/router/RouteEntry.js +91 -0
  79. package/src/router/Router.js +71 -1
  80. package/src/scaffold/maker.js +138 -1
  81. package/src/scaffold/templates.js +12 -0
  82. package/src/serializer/Serializer.js +239 -0
  83. package/src/support/Str.js +1080 -0
  84. package/src/validation/BaseValidator.js +45 -5
  85. package/src/validation/Validator.js +67 -61
  86. package/src/validation/types.js +490 -0
  87. package/src/middleware/AuthMiddleware.js +0 -46
  88. package/src/middleware/MiddlewareRegistry.js +0 -106
@@ -0,0 +1,1181 @@
1
+ /* ─────────────────────────────────────────────────────────────────────────────
2
+ * Millas API Docs — Single-file client app
3
+ * No build step. Vanilla JS, no dependencies.
4
+ * ─────────────────────────────────────────────────────────────────────────── */
5
+
6
+ 'use strict';
7
+
8
+ // Read prefix + admin prefix from data attributes — avoids inline script CSP violation
9
+ const _appEl = document.getElementById('app') || {};
10
+ const PREFIX = _appEl.dataset?.prefix || '/docs';
11
+ const ADMIN_PREFIX = _appEl.dataset?.adminPrefix || '/admin';
12
+
13
+ // ── State ─────────────────────────────────────────────────────────────────────
14
+
15
+ const state = {
16
+ manifest: null,
17
+ activeEp: null, // { groupIdx, epIdx }
18
+ loading: false,
19
+ response: null, // last "Try it" response
20
+ trying: false,
21
+ search: '',
22
+ env: {
23
+ baseUrl: localStorage.getItem('docs_baseUrl') || window.location.origin,
24
+ token: localStorage.getItem('docs_token') || '',
25
+ },
26
+ environments: JSON.parse(localStorage.getItem('docs_envs') || 'null') || [
27
+ { name: 'Local', baseUrl: window.location.origin, token: '' },
28
+ { name: 'Staging', baseUrl: 'https://staging.example.com', token: '' },
29
+ { name: 'Production', baseUrl: 'https://api.example.com', token: '' },
30
+ ],
31
+ activeEnvIdx: parseInt(localStorage.getItem('docs_activeEnv') || '0'),
32
+ _envModal: null, // UI.Modal instance — managed by UI, not state
33
+ fieldValues: {}, // { fieldKey: value } — persisted per endpoint
34
+ activeTab: 'curl',
35
+ bodyMode: 'form', // 'form' | 'raw'
36
+ history: [], // last 20 requests globally
37
+ showHistory: false,
38
+ };
39
+
40
+ // ── Bootstrap ─────────────────────────────────────────────────────────────────
41
+
42
+ async function init() {
43
+ // Load from active environment on startup
44
+ _syncEnvFromActive();
45
+ render();
46
+ try {
47
+ const r = await fetch(`${PREFIX}/_api/manifest`);
48
+ const j = await r.json();
49
+ if (!j.ok || !j.data) {
50
+ const msg = j.error || 'Server returned an error';
51
+ document.getElementById('app').innerHTML =
52
+ `<div class="empty-state"><i class="bi bi-exclamation-triangle"></i><p>Failed to load manifest: ${msg}</p></div>`;
53
+ return;
54
+ }
55
+ state.manifest = j.data;
56
+ // Auto-open all groups
57
+ state.openGroups = new Set(state.manifest.groups.map((_, i) => i));
58
+ render();
59
+ } catch (err) {
60
+ document.getElementById('app').innerHTML =
61
+ `<div class="empty-state"><i class="bi bi-exclamation-triangle"></i><p>Failed to load manifest: ${err.message}</p></div>`;
62
+ }
63
+ }
64
+
65
+ function _syncEnvFromActive() {
66
+ const env = state.environments[state.activeEnvIdx];
67
+ if (env) {
68
+ state.env.baseUrl = env.baseUrl;
69
+ state.env.token = env.token;
70
+ }
71
+ }
72
+
73
+ function _saveEnvs() {
74
+ localStorage.setItem('docs_envs', JSON.stringify(state.environments));
75
+ localStorage.setItem('docs_activeEnv', String(state.activeEnvIdx));
76
+ localStorage.setItem('docs_baseUrl', state.env.baseUrl);
77
+ localStorage.setItem('docs_token', state.env.token);
78
+ }
79
+
80
+ // ── Rendering ─────────────────────────────────────────────────────────────────
81
+
82
+ // Track what's been initially mounted
83
+ let _appMounted = false;
84
+
85
+ function render() {
86
+ const app = document.getElementById('app');
87
+ if (!app) return;
88
+
89
+ const m = state.manifest;
90
+ const title = m ? m.title : 'API Docs';
91
+
92
+ // ── Initial mount — build the shell once ─────────────────────────────────
93
+ if (!_appMounted) {
94
+ app.innerHTML = `
95
+ <aside class="sidebar">
96
+ ${renderSidebar(m, title)}
97
+ </aside>
98
+ <div class="main">
99
+ ${renderEnvBar()}
100
+ <div class="detail">
101
+ ${renderDetail()}
102
+ </div>
103
+ </div>
104
+ `;
105
+ _appMounted = true;
106
+ bindEvents();
107
+ return;
108
+ }
109
+
110
+ // ── Subsequent renders — surgical updates only ────────────────────────────
111
+
112
+ // 1. Sidebar: only rebuild when manifest or search changes (group open/close, search)
113
+ // but NOT on endpoint selection — just swap the active class instead
114
+ const sidebarEl = app.querySelector('.sidebar');
115
+
116
+ if (state._sidebarDirty) {
117
+ const savedScrollTop = app.querySelector('.sidebar-scroll')?.scrollTop || 0;
118
+ if (sidebarEl) sidebarEl.innerHTML = renderSidebar(m, title);
119
+ const newScroll = app.querySelector('.sidebar-scroll');
120
+ if (newScroll && savedScrollTop) newScroll.scrollTop = savedScrollTop;
121
+ state._sidebarDirty = false;
122
+ bindSidebarEvents();
123
+ } else {
124
+ // Just swap the active class — no DOM rebuild, no scroll reset
125
+ app.querySelectorAll('.sidebar-ep').forEach(el => {
126
+ const [path, verb] = (el.dataset.ep || '').split('|');
127
+ const isActive = state.activeEp &&
128
+ state.activeEp.group === el.dataset.group &&
129
+ state.activeEp.ep === path + verb;
130
+ el.classList.toggle('active', isActive);
131
+ });
132
+ }
133
+
134
+ // 2. Detail panel: always update when render() is called after initial mount
135
+ const detailEl = app.querySelector('.detail');
136
+ if (detailEl) {
137
+ detailEl.innerHTML = renderDetail();
138
+ bindDetailEvents();
139
+ }
140
+
141
+ // 3. Env bar: only update on env changes
142
+ if (state._envDirty) {
143
+ const envBar = app.querySelector('.env-bar');
144
+ if (envBar) {
145
+ const tmp = document.createElement('div');
146
+ tmp.innerHTML = renderEnvBar();
147
+ envBar.replaceWith(tmp.firstElementChild);
148
+ }
149
+ state._envDirty = false;
150
+ bindEnvEvents();
151
+ }
152
+ }
153
+
154
+ function renderSidebar(m, title) {
155
+ if (!m) {
156
+ return `
157
+ <div class="sidebar-header">
158
+ <div class="sidebar-logo"><i class="bi bi-code-slash"></i></div>
159
+ <h1>${title}</h1>
160
+ </div>
161
+ <div class="empty-state" style="padding:32px;text-align:center;">
162
+ <div class="spinner"></div>
163
+ <p style="margin-top:12px;color:var(--text-3)">Loading…</p>
164
+ </div>`;
165
+ }
166
+
167
+ const groups = filterGroups(m.groups, state.search);
168
+
169
+ let groupHtml = '';
170
+ groups.forEach((group, gi) => {
171
+ const isOpen = !state.openGroups || state.openGroups.has(gi);
172
+ const eps = group.endpoints.map((ep, ei) => {
173
+ const isActive = state.activeEp &&
174
+ state.activeEp.group === group.slug &&
175
+ state.activeEp.ep === ep.path + ep.verb;
176
+ return `
177
+ <div class="sidebar-ep ${isActive ? 'active' : ''}"
178
+ data-group="${group.slug}" data-ep="${ep.path}|${ep.verb}">
179
+ <div class="sidebar-ep-left">
180
+ <span class="verb-badge verb-${ep.verb.toUpperCase()}">${ep.verb.toUpperCase()}</span>
181
+ </div>
182
+ <div class="sidebar-ep-info">
183
+ <div class="ep-name">
184
+ ${ep.label}
185
+ ${ep.auth ? '<i class="bi bi-lock-fill" style="color:var(--purple);font-size:9px;margin-left:3px;"></i>' : ''}
186
+ </div>
187
+ <div class="ep-path-hint">${_esc(ep.shortPath || ep.path)}</div>
188
+ </div>
189
+ </div>`;
190
+ }).join('');
191
+
192
+ groupHtml += `
193
+ <div class="sidebar-group ${isOpen ? 'open' : ''}" data-group-idx="${gi}">
194
+ <div class="sidebar-group-header" data-toggle="${gi}">
195
+ <i class="bi bi-${group.icon || 'code-slash'}"></i>
196
+ <span>${group.label}</span>
197
+ <span class="group-count-badge">${group.endpoints.length}</span>
198
+ <i class="bi bi-chevron-right sidebar-group-chevron"></i>
199
+ </div>
200
+ <div class="sidebar-group-endpoints">${eps}</div>
201
+ </div>`;
202
+ });
203
+
204
+ return `
205
+ <div class="sidebar-header">
206
+ <div class="sidebar-logo"><i class="bi bi-braces"></i></div>
207
+ <h1>${title}</h1>
208
+ </div>
209
+ <div class="sidebar-search">
210
+ <input type="text" placeholder="Search endpoints…" id="search-input" value="${_esc(state.search)}" />
211
+ </div>
212
+ <div class="sidebar-scroll">${groupHtml || '<div style="padding:20px;color:var(--text-3);text-align:center;">No results</div>'}</div>
213
+ <div class="sidebar-footer">
214
+ ${countEndpoints(m)} endpoints &nbsp;·&nbsp; ${m.groups.length} groups
215
+ </div>`;
216
+ }
217
+
218
+ function renderEnvBar() {
219
+ const activeEnv = state.environments[state.activeEnvIdx] || {};
220
+ const envOptions = state.environments.map((e, i) =>
221
+ `<option value="${i}" ${i === state.activeEnvIdx ? 'selected' : ''}>${_esc(e.name)}</option>`
222
+ ).join('');
223
+
224
+ return `
225
+ <div class="env-bar">
226
+ <label>Env</label>
227
+ <select class="env-select" id="env-select">${envOptions}</select>
228
+ <button class="btn btn-ghost btn-sm" id="env-manage-btn" title="Manage environments">
229
+ <i class="bi bi-pencil"></i>
230
+ </button>
231
+ <label style="margin-left:4px;">URL</label>
232
+ <input type="text" class="base-url-input" id="env-baseUrl"
233
+ value="${_esc(state.env.baseUrl)}" placeholder="http://localhost:3000" />
234
+ <label>Token</label>
235
+ <input type="text" class="token-input" id="env-token"
236
+ value="${_esc(state.env.token)}" placeholder="eyJ..." />
237
+ <div class="export-btn">
238
+ <button class="btn btn-ghost btn-sm" id="export-postman">
239
+ <i class="bi bi-box-arrow-down"></i> Postman
240
+ </button>
241
+ <button class="btn btn-ghost btn-sm" id="export-openapi">
242
+ <i class="bi bi-filetype-json"></i> OpenAPI
243
+ </button>
244
+ </div>
245
+ </div>
246
+ `; // env modal is opened via UI.Modal — not rendered inline
247
+ }
248
+
249
+ function _openEnvModal() {
250
+ // Build modal body as a live DOM element so inputs work without re-renders
251
+ const body = document.createElement('div');
252
+
253
+ function _rebuildRows() {
254
+ body.innerHTML = '';
255
+
256
+ const table = document.createElement('table');
257
+ table.className = 'field-table';
258
+ table.style.marginBottom = '12px';
259
+ table.innerHTML = '<thead><tr><th>Name</th><th>Base URL</th><th>Token</th><th></th></tr></thead>';
260
+ const tbody = document.createElement('tbody');
261
+
262
+ state.environments.forEach((e, i) => {
263
+ const tr = document.createElement('tr');
264
+ tr.innerHTML = `
265
+ <td><input class="field-input" data-idx="${i}" data-field="name"
266
+ value="${_esc(e.name)}" placeholder="Environment name" /></td>
267
+ <td><input class="field-input" data-idx="${i}" data-field="baseUrl"
268
+ value="${_esc(e.baseUrl)}" placeholder="https://..." /></td>
269
+ <td><input class="field-input" data-idx="${i}" data-field="token"
270
+ value="${_esc(e.token)}" placeholder="eyJ..." /></td>
271
+ <td>
272
+ <button class="btn btn-ghost btn-sm env-del-btn" data-idx="${i}"
273
+ ${state.environments.length <= 1 ? 'disabled' : ''}>
274
+ <i class="bi bi-trash"></i>
275
+ </button>
276
+ </td>`;
277
+ tbody.appendChild(tr);
278
+ });
279
+
280
+ table.appendChild(tbody);
281
+ body.appendChild(table);
282
+
283
+ // Add env button
284
+ const addBtn = document.createElement('button');
285
+ addBtn.className = 'btn btn-ghost btn-sm';
286
+ addBtn.innerHTML = '<i class="bi bi-plus"></i> Add environment';
287
+ addBtn.addEventListener('click', () => {
288
+ state.environments.push({ name: `Env ${state.environments.length + 1}`, baseUrl: '', token: '' });
289
+ _saveEnvs();
290
+ _rebuildRows();
291
+ });
292
+ body.appendChild(addBtn);
293
+
294
+ // Wire input events
295
+ body.querySelectorAll('[data-idx]').forEach(inp => {
296
+ inp.addEventListener('input', e => {
297
+ const idx = parseInt(e.target.dataset.idx);
298
+ const field = e.target.dataset.field;
299
+ if (!field) return;
300
+ state.environments[idx][field] = e.target.value;
301
+ if (idx === state.activeEnvIdx) {
302
+ state.env.baseUrl = state.environments[idx].baseUrl;
303
+ state.env.token = state.environments[idx].token;
304
+ const urlBar = document.getElementById('env-baseUrl');
305
+ const tokBar = document.getElementById('env-token');
306
+ if (urlBar) urlBar.value = state.env.baseUrl;
307
+ if (tokBar) tokBar.value = state.env.token;
308
+ }
309
+ _saveEnvs();
310
+ });
311
+ });
312
+
313
+ // Wire delete buttons
314
+ body.querySelectorAll('.env-del-btn').forEach(btn => {
315
+ btn.addEventListener('click', () => {
316
+ const idx = parseInt(btn.dataset.idx);
317
+ state.environments.splice(idx, 1);
318
+ if (state.activeEnvIdx >= state.environments.length) state.activeEnvIdx = 0;
319
+ _syncEnvFromActive();
320
+ _saveEnvs();
321
+ _rebuildRows();
322
+ // Refresh env select in the bar
323
+ const sel = document.getElementById('env-select');
324
+ if (sel) {
325
+ while (sel.options.length) sel.remove(0);
326
+ state.environments.forEach((e, i) => {
327
+ const opt = new Option(e.name, i, false, i === state.activeEnvIdx);
328
+ sel.add(opt);
329
+ });
330
+ }
331
+ });
332
+ });
333
+ }
334
+
335
+ _rebuildRows();
336
+
337
+ // Use UI.Modal — Portal rendering, FocusTrap, ScrollLock, Escape handling
338
+ const m = UI.Modal.create({
339
+ title: 'Manage Environments',
340
+ content: body,
341
+ size: 'lg',
342
+ onClose: () => { state._envModal = null; },
343
+ });
344
+
345
+ state._envModal = m;
346
+ m.open();
347
+ }
348
+
349
+ function renderDetail() {
350
+ if (!state.manifest) {
351
+ return `<div class="empty-state"><div class="spinner"></div></div>`;
352
+ }
353
+
354
+ if (!state.activeEp) {
355
+ const total = countEndpoints(state.manifest);
356
+ const groups = state.manifest.groups.length;
357
+ return `
358
+ <div class="empty-state">
359
+ <i class="bi bi-braces" style="font-size:48px;color:var(--border-2)"></i>
360
+ <p style="font-weight:600;color:var(--text-2);">Select an endpoint to get started</p>
361
+ <p style="font-size:12px;color:var(--text-3);">${total} endpoints &nbsp;·&nbsp; ${groups} groups</p>
362
+ <div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:center;max-width:400px;margin-top:8px;">
363
+ ${state.manifest.groups.slice(0,8).map(g =>
364
+ `<span style="font-size:11px;padding:3px 10px;border-radius:20px;background:var(--bg-3);border:1px solid var(--border);color:var(--text-2);">
365
+ <i class="bi bi-${g.icon || 'code-slash'}"></i> ${g.label}
366
+ </span>`
367
+ ).join('')}
368
+ </div>
369
+ </div>`;
370
+ }
371
+
372
+ const ep = getActiveEp();
373
+ if (!ep) return `<div class="empty-state"><p>Endpoint not found</p></div>`;
374
+
375
+ // Find the group this endpoint belongs to for description
376
+ const epGroup = state.manifest.groups.find(g => g.slug === state.activeEp.group);
377
+
378
+ return `
379
+ ${renderEpHeader(ep)}
380
+ ${epGroup?.description && !state.activeEp._descShown ? `
381
+ <div class="section" style="background:var(--accent-bg);border-left:3px solid var(--accent);padding:12px 20px;">
382
+ <span style="font-size:12px;color:var(--text-2);">
383
+ <i class="bi bi-info-circle" style="color:var(--accent);margin-right:6px;"></i>${_esc(epGroup.description)}
384
+ </span>
385
+ </div>` : ''}
386
+ ${renderTryBar(ep)}
387
+ ${renderParams(ep)}
388
+ ${renderQueryParams(ep)}
389
+ ${renderBody(ep)}
390
+ ${renderExpectedResponses(ep)}
391
+ ${state.response ? renderResponse(state.response) : ''}
392
+ ${state.response ? renderCodeSnippets(ep) : ''}
393
+ ${renderHistory()}
394
+ `;
395
+ }
396
+
397
+ function renderEpHeader(ep) {
398
+ const authBadge = ep.auth
399
+ ? `<span class="badge badge-auth"><i class="bi bi-lock-fill"></i> Auth required</span>`
400
+ : `<span class="badge badge-public">Public</span>`;
401
+ const depBadge = ep.deprecated ? `<span class="badge badge-deprecated">Deprecated</span>` : '';
402
+ const autoBadge = ep.autoDiscovered ? `<span class="badge badge-auto">Auto-discovered</span>` : '';
403
+
404
+ return `
405
+ <div class="ep-header">
406
+ <div class="ep-header-top">
407
+ <span class="verb-badge verb-${ep.verb.toUpperCase()}">${ep.verb.toUpperCase()}</span>
408
+ <code class="ep-path">${_esc(ep.shortPath || ep.path)}</code>
409
+ ${authBadge}${depBadge}${autoBadge}
410
+ </div>
411
+ <div class="ep-label">${_esc(ep.label)}</div>
412
+ ${ep.description ? `<div class="ep-description">${_esc(ep.description)}</div>` : ''}
413
+ </div>`;
414
+ }
415
+
416
+ function renderTryBar(ep) {
417
+ const url = buildUrl(ep);
418
+ return `
419
+ <div class="try-bar">
420
+ <input type="text" class="try-url" id="try-url" value="${_esc(url)}"
421
+ title="Press Enter to send" />
422
+ <button class="btn btn-ghost btn-sm" id="copy-url-btn" title="Copy URL">
423
+ <i class="bi bi-clipboard"></i>
424
+ </button>
425
+ <button class="btn btn-primary" id="try-btn" ${state.trying ? 'disabled' : ''}>
426
+ ${state.trying
427
+ ? '<span class="spinner"></span> Sending…'
428
+ : '<i class="bi bi-play-fill"></i> Send'}
429
+ </button>
430
+ </div>`;
431
+ }
432
+
433
+ function renderParams(ep) {
434
+ const allParams = [...(ep.pathParams || [])];
435
+ if (!allParams.length) return '';
436
+
437
+ const rows = allParams.map(name => {
438
+ const def = ep.params?.[name] || {};
439
+ const key = `param:${name}`;
440
+ const val = state.fieldValues[key] !== undefined ? state.fieldValues[key] : (def.example ?? '');
441
+ return `
442
+ <tr>
443
+ <td><span class="field-name">${name}</span></td>
444
+ <td><span class="field-type">string</span></td>
445
+ <td><span class="field-required">required</span></td>
446
+ <td>
447
+ <input class="field-input" data-key="${key}" value="${_esc(String(val))}"
448
+ placeholder="${_esc(String(def.example ?? ''))}" />
449
+ ${def.description ? `<div class="field-desc">${_esc(def.description)}</div>` : ''}
450
+ </td>
451
+ </tr>`;
452
+ }).join('');
453
+
454
+ return `
455
+ <div class="section">
456
+ <div class="section-title"><i class="bi bi-link-45deg"></i> Path Parameters</div>
457
+ <table class="field-table">
458
+ <thead><tr><th>Name</th><th>Type</th><th>Required</th><th>Value</th></tr></thead>
459
+ <tbody>${rows}</tbody>
460
+ </table>
461
+ </div>`;
462
+ }
463
+
464
+ function renderQueryParams(ep) {
465
+ const q = ep.query || {};
466
+ if (!Object.keys(q).length) return '';
467
+
468
+ const rows = Object.entries(q).map(([name, def]) => {
469
+ const key = `query:${name}`;
470
+ const val = state.fieldValues[key] !== undefined ? state.fieldValues[key] : (def.example ?? '');
471
+ return `
472
+ <tr>
473
+ <td><span class="field-name">${name}</span></td>
474
+ <td><span class="field-type">${def.type || 'string'}</span></td>
475
+ <td>${def.required ? '<span class="field-required">required</span>' : '<span style="color:var(--text-3);font-size:11px">optional</span>'}</td>
476
+ <td>
477
+ <input class="field-input" data-key="${key}" value="${_esc(String(val))}"
478
+ placeholder="${_esc(String(def.example ?? ''))}" />
479
+ ${def.description ? `<div class="field-desc">${_esc(def.description)}</div>` : ''}
480
+ </td>
481
+ </tr>`;
482
+ }).join('');
483
+
484
+ return `
485
+ <div class="section">
486
+ <div class="section-title"><i class="bi bi-question-circle"></i> Query Parameters</div>
487
+ <table class="field-table">
488
+ <thead><tr><th>Name</th><th>Type</th><th>Required</th><th>Value</th></tr></thead>
489
+ <tbody>${rows}</tbody>
490
+ </table>
491
+ </div>`;
492
+ }
493
+
494
+ function renderBody(ep) {
495
+ const body = ep.body || {};
496
+ if (!Object.keys(body).length || ep.verb === 'get') return '';
497
+
498
+ const isRaw = state.bodyMode === 'raw';
499
+ const rawKey = `raw:${ep.verb}:${ep.path}`;
500
+
501
+ // Build raw JSON from current field values for initial raw content
502
+ const currentObj = {};
503
+ for (const [name, def] of Object.entries(body)) {
504
+ const k = `body:${name}`;
505
+ const val = state.fieldValues[k] !== undefined ? state.fieldValues[k] : (def.example ?? '');
506
+ if (val !== '' && val !== undefined) currentObj[name] = val;
507
+ }
508
+ const rawVal = state.fieldValues[rawKey] !== undefined
509
+ ? state.fieldValues[rawKey]
510
+ : JSON.stringify(currentObj, null, 2);
511
+
512
+ const rows = Object.entries(body).map(([name, def]) => {
513
+ const key = `body:${name}`;
514
+ const val = state.fieldValues[key] !== undefined ? state.fieldValues[key] : (def.example ?? '');
515
+ const input = renderFieldInput(key, def, val);
516
+ return `
517
+ <tr>
518
+ <td><span class="field-name">${name}</span></td>
519
+ <td><span class="field-type">${def.type || 'string'}</span></td>
520
+ <td>${def.required ? '<span class="field-required">required</span>' : '<span style="color:var(--text-3);font-size:11px">optional</span>'}</td>
521
+ <td>
522
+ ${input}
523
+ ${def.description ? `<div class="field-desc">${_esc(def.description)}</div>` : ''}
524
+ ${def.enum ? `<div class="field-desc">Options: ${def.enum.map(e => typeof e === 'object' ? e.value : e).join(', ')}</div>` : ''}
525
+ </td>
526
+ </tr>`;
527
+ }).join('');
528
+
529
+ const isMultipart = ep.bodyEncoding === 'multipart';
530
+
531
+ return `
532
+ <div class="section">
533
+ <div class="section-title" style="justify-content:space-between;">
534
+ <span><i class="bi bi-body-text"></i> Request Body
535
+ <span style="margin-left:6px;font-size:10px;color:var(--text-3)">${ep.bodyEncoding || 'json'}</span>
536
+ </span>
537
+ <div class="body-mode-toggle">
538
+ <button class="mode-btn ${!isRaw ? 'active' : ''}" data-mode="form">Form</button>
539
+ <button class="mode-btn ${isRaw ? 'active' : ''}" data-mode="raw">Raw JSON</button>
540
+ </div>
541
+ </div>
542
+ ${isRaw ? `
543
+ <textarea class="field-input raw-body-input" id="raw-body-input" data-key="${rawKey}"
544
+ rows="10" style="width:100%;resize:vertical;font-family:var(--mono);font-size:12px;"
545
+ placeholder='{"key": "value"}'>${_esc(rawVal)}</textarea>` : `
546
+ <table class="field-table">
547
+ <thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Value</th></tr></thead>
548
+ <tbody>${rows}</tbody>
549
+ </table>`}
550
+ ${isMultipart ? `
551
+ <div style="margin-top:8px;padding:8px 10px;background:rgba(154,103,0,.06);border:1px solid rgba(154,103,0,.2);border-radius:var(--radius);font-size:11px;color:var(--orange);">
552
+ <i class="bi bi-info-circle"></i>
553
+ This endpoint accepts file uploads. The "Try it" panel sends non-file fields only.
554
+ Use a tool like Postman or curl for full multipart upload testing.
555
+ </div>` : ''}
556
+ </div>`;
557
+ }
558
+
559
+ function renderFieldInput(key, def, val) {
560
+ if (def.type === 'boolean') {
561
+ return `<input type="checkbox" class="field-input boolean-input" data-key="${key}" data-type="boolean"
562
+ ${val === true || val === 'true' ? 'checked' : ''} />`;
563
+ }
564
+ if (def.type === 'select' && def.enum) {
565
+ const opts = def.enum.map(e => {
566
+ const v = typeof e === 'object' ? e.value : e;
567
+ const l = typeof e === 'object' ? e.label : e;
568
+ return `<option value="${_esc(v)}" ${val == v ? 'selected' : ''}>${_esc(l)}</option>`;
569
+ }).join('');
570
+ return `<select class="field-input" data-key="${key}"><option value="">— select —</option>${opts}</select>`;
571
+ }
572
+ if (def.type === 'array') {
573
+ const textVal = Array.isArray(val) ? JSON.stringify(val) : String(val ?? '');
574
+ return `
575
+ <input class="field-input" data-key="${key}" data-type="array"
576
+ value="${_esc(textVal)}" placeholder='[1, 2, 3] or ["a", "b"]' />
577
+ <div class="field-desc">Enter a JSON array</div>`;
578
+ }
579
+ if (def.type === 'json') {
580
+ const textVal = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val ?? '');
581
+ return `<textarea class="field-input" data-key="${key}" rows="3" style="resize:vertical">${_esc(textVal)}</textarea>`;
582
+ }
583
+ const inputType = def.type === 'email' ? 'email'
584
+ : def.type === 'password' ? 'password'
585
+ : def.type === 'number' || def.type === 'integer' ? 'number'
586
+ : 'text';
587
+ return `<input type="${inputType}" class="field-input" data-key="${key}"
588
+ value="${_esc(String(val ?? ''))}" placeholder="${_esc(String(def.example ?? ''))}" />`;
589
+ }
590
+
591
+ function renderExpectedResponses(ep) {
592
+ const resps = ep.responses || [];
593
+ if (!resps.length) return '';
594
+
595
+ const items = resps.map(r => `
596
+ <div class="response-doc">
597
+ <span class="status-badge ${_statusClass(r.status)}">${r.status}</span>
598
+ <div>
599
+ ${r.description ? `<div style="font-size:12px;color:var(--text-2)">${_esc(r.description)}</div>` : ''}
600
+ ${r.example ? `<pre class="code-block" style="margin-top:6px;max-height:120px">${_esc(JSON.stringify(r.example, null, 2))}</pre>` : ''}
601
+ </div>
602
+ </div>`).join('');
603
+
604
+ return `
605
+ <div class="section">
606
+ <div class="section-title"><i class="bi bi-card-list"></i> Expected Responses</div>
607
+ ${items}
608
+ </div>`;
609
+ }
610
+
611
+ function renderResponse(r) {
612
+ // ── 204 No Content and other empty bodies ──────────────────────────────
613
+ const isEmpty = r.status === 204 || r.body === '' || r.body === null || r.body === undefined;
614
+ const bodyStr = isEmpty
615
+ ? null
616
+ : (typeof r.body === 'string' ? r.body : JSON.stringify(r.body, null, 2));
617
+
618
+ // ── Response headers ───────────────────────────────────────────────────
619
+ const importantHeaders = ['content-type', 'x-request-id', 'x-ratelimit-limit',
620
+ 'x-ratelimit-remaining', 'x-ratelimit-reset', 'retry-after', 'location',
621
+ 'cache-control', 'etag', 'last-modified', 'authorization', 'www-authenticate'];
622
+ const headers = r.headers || {};
623
+ const headerRows = Object.entries(headers)
624
+ .filter(([k]) => importantHeaders.includes(k.toLowerCase()) || k.toLowerCase().startsWith('x-'))
625
+ .map(([k, v]) => `
626
+ <tr>
627
+ <td style="font-family:var(--mono);font-size:11px;color:var(--accent);white-space:nowrap;padding:4px 8px;">${_esc(k)}</td>
628
+ <td style="font-family:var(--mono);font-size:11px;color:var(--text-2);padding:4px 8px;word-break:break-all;">${_esc(String(v))}</td>
629
+ </tr>`).join('');
630
+
631
+ const headersSection = headerRows ? `
632
+ <div style="margin-top:12px;">
633
+ <div class="section-title" style="margin-bottom:6px;"><i class="bi bi-list-ul"></i> Response Headers</div>
634
+ <table class="field-table" style="font-size:11px;">
635
+ <thead><tr><th>Header</th><th>Value</th></tr></thead>
636
+ <tbody>${headerRows}</tbody>
637
+ </table>
638
+ </div>` : '';
639
+
640
+ // ── Body ───────────────────────────────────────────────────────────────
641
+ const bodySection = isEmpty
642
+ ? `<div style="padding:16px;text-align:center;color:var(--text-3);font-size:12px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius);">
643
+ <i class="bi bi-check-circle" style="color:var(--green);font-size:18px;display:block;margin-bottom:6px;"></i>
644
+ ${r.status} ${_statusText(r.status)} — no response body
645
+ </div>`
646
+ : `<pre class="code-block" id="response-body">${_syntaxHighlight(bodyStr)}</pre>`;
647
+
648
+ return `
649
+ <div class="response-section section" style="background:var(--bg-2);border-top:1px solid var(--border)">
650
+ <div class="section-title"><i class="bi bi-arrow-return-left"></i> Response</div>
651
+ <div class="response-meta">
652
+ <span class="status-badge ${_statusClass(r.status)}">${r.status} ${_statusText(r.status)}</span>
653
+ <span class="response-time"><i class="bi bi-clock"></i> ${r.time}ms</span>
654
+ ${!isEmpty ? `
655
+ <button class="btn btn-ghost btn-sm" id="copy-response">
656
+ <i class="bi bi-clipboard"></i> Copy
657
+ </button>
658
+ <button class="btn btn-ghost btn-sm" id="clear-response">
659
+ <i class="bi bi-x"></i> Clear
660
+ </button>` : `
661
+ <button class="btn btn-ghost btn-sm" id="clear-response">
662
+ <i class="bi bi-x"></i> Clear
663
+ </button>`}
664
+ </div>
665
+ ${bodySection}
666
+ ${headersSection}
667
+ </div>`;
668
+ }
669
+
670
+ function renderHistory() {
671
+ if (!state.history.length) return '';
672
+
673
+ const rows = state.history.map((h, i) => `
674
+ <tr class="history-row" data-idx="${i}" style="cursor:pointer;">
675
+ <td><span class="verb-badge verb-${h.verb.toUpperCase()}" style="font-size:9px;">${h.verb.toUpperCase()}</span></td>
676
+ <td style="font-family:var(--mono);font-size:11px;color:var(--text-2);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(h.label)}</td>
677
+ <td><span class="status-badge ${_statusClass(h.status)}" style="font-size:10px;">${h.status}</span></td>
678
+ <td style="font-size:11px;color:var(--text-3);">${h.time}ms</td>
679
+ <td style="font-size:11px;color:var(--text-3);">${h.ts}</td>
680
+ </tr>`).join('');
681
+
682
+ const expanded = state.showHistory;
683
+
684
+ return `
685
+ <div class="section" style="background:var(--bg);">
686
+ <div class="section-title" style="cursor:pointer;justify-content:space-between;" id="toggle-history">
687
+ <span><i class="bi bi-clock-history"></i> Request History
688
+ <span class="group-count-badge" style="margin-left:6px;">${state.history.length}</span>
689
+ </span>
690
+ <i class="bi bi-chevron-${expanded ? 'up' : 'down'}" style="font-size:11px;"></i>
691
+ </div>
692
+ ${expanded ? `
693
+ <table class="field-table">
694
+ <thead><tr><th>Method</th><th>Endpoint</th><th>Status</th><th>Time</th><th>At</th></tr></thead>
695
+ <tbody>${rows}</tbody>
696
+ </table>
697
+ <div style="margin-top:8px;">
698
+ <button class="btn btn-ghost btn-sm" id="clear-history">
699
+ <i class="bi bi-trash"></i> Clear history
700
+ </button>
701
+ </div>` : ''}
702
+ </div>`;
703
+ }
704
+
705
+ function renderCodeSnippets(ep) {
706
+ const url = document.getElementById('try-url')?.value || buildUrl(ep);
707
+ const body = collectBody(ep);
708
+ const token = state.env.token;
709
+ const verb = ep.verb.toUpperCase();
710
+
711
+ const curlLines = [`curl -X ${verb} "${url}"`];
712
+ if (ep.auth && token) curlLines.push(` -H "Authorization: Bearer ${token}"`);
713
+ curlLines.push(` -H "Content-Type: application/json"`);
714
+ if (body && verb !== 'GET') curlLines.push(` -d '${JSON.stringify(body)}'`);
715
+ const curlStr = curlLines.join(' \\\n');
716
+
717
+ const fetchHeaders = { 'Content-Type': 'application/json' };
718
+ if (ep.auth && token) fetchHeaders['Authorization'] = `Bearer ${token}`;
719
+ const fetchStr = `fetch("${url}", {
720
+ method: "${verb}",
721
+ headers: ${JSON.stringify(fetchHeaders, null, 2)},${body && verb !== 'GET' ? `\n body: JSON.stringify(${JSON.stringify(body, null, 2)}),` : ''}
722
+ })
723
+ .then(r => r.json())
724
+ .then(console.log);`;
725
+
726
+ const axiosStr = `axios.${ep.verb}("${url}"${body && verb !== 'GET' ? `, ${JSON.stringify(body, null, 2)}` : ''}, {
727
+ headers: ${JSON.stringify(fetchHeaders, null, 2)},
728
+ }).then(r => console.log(r.data));`;
729
+
730
+ const active = state.activeTab;
731
+ return `
732
+ <div class="section">
733
+ <div class="section-title"><i class="bi bi-code"></i> Code</div>
734
+ <div class="tabs">
735
+ <button class="tab-btn ${active==='curl'?'active':''}" data-tab="curl">curl</button>
736
+ <button class="tab-btn ${active==='fetch'?'active':''}" data-tab="fetch">fetch</button>
737
+ <button class="tab-btn ${active==='axios'?'active':''}" data-tab="axios">axios</button>
738
+ </div>
739
+ <div class="tab-panel ${active==='curl' ?'active':''}" data-panel="curl">
740
+ <pre class="code-block">${_esc(curlStr)}</pre>
741
+ </div>
742
+ <div class="tab-panel ${active==='fetch'?'active':''}" data-panel="fetch">
743
+ <pre class="code-block">${_esc(fetchStr)}</pre>
744
+ </div>
745
+ <div class="tab-panel ${active==='axios'?'active':''}" data-panel="axios">
746
+ <pre class="code-block">${_esc(axiosStr)}</pre>
747
+ </div>
748
+ </div>`;
749
+ }
750
+
751
+ // ── Events ─────────────────────────────────────────────────────────────────────
752
+
753
+ function bindEvents() {
754
+ bindSidebarEvents();
755
+ bindEnvEvents();
756
+ bindDetailEvents();
757
+ }
758
+
759
+ function bindSidebarEvents() {
760
+ const search = document.getElementById('search-input');
761
+ if (search) {
762
+ search.addEventListener('input', e => {
763
+ state.search = e.target.value;
764
+ state._sidebarDirty = true;
765
+ render();
766
+ });
767
+ }
768
+
769
+ document.querySelectorAll('.sidebar-group-header').forEach(el => {
770
+ el.addEventListener('click', () => {
771
+ const idx = parseInt(el.dataset.toggle);
772
+ if (!state.openGroups) state.openGroups = new Set();
773
+ if (state.openGroups.has(idx)) state.openGroups.delete(idx);
774
+ else state.openGroups.add(idx);
775
+ state._sidebarDirty = true;
776
+ render();
777
+ });
778
+ });
779
+
780
+ document.querySelectorAll('.sidebar-ep').forEach(el => {
781
+ el.addEventListener('click', () => {
782
+ const [path, verb] = el.dataset.ep.split('|');
783
+ state.activeEp = { group: el.dataset.group, ep: path + verb };
784
+ state.response = null;
785
+ state.bodyMode = 'form';
786
+ const savedKey = `fields:${verb}:${path}`;
787
+ state.fieldValues = JSON.parse(localStorage.getItem(savedKey) || '{}');
788
+ // No _sidebarDirty — active class is swapped in-place, scroll untouched
789
+ render();
790
+ });
791
+ });
792
+ }
793
+
794
+ function bindEnvEvents() {
795
+ const envSelect = document.getElementById('env-select');
796
+ if (envSelect) {
797
+ envSelect.addEventListener('change', e => {
798
+ state.activeEnvIdx = parseInt(e.target.value);
799
+ _syncEnvFromActive();
800
+ _saveEnvs();
801
+ state._envDirty = true;
802
+ render();
803
+ });
804
+ }
805
+
806
+ const envManageBtn = document.getElementById('env-manage-btn');
807
+ if (envManageBtn) {
808
+ envManageBtn.addEventListener('click', () => {
809
+ if (state._envModal) { state._envModal.close(); return; }
810
+ _openEnvModal();
811
+ });
812
+ }
813
+
814
+ const baseUrl = document.getElementById('env-baseUrl');
815
+ if (baseUrl) {
816
+ baseUrl.addEventListener('change', e => {
817
+ state.env.baseUrl = e.target.value;
818
+ if (state.environments[state.activeEnvIdx]) {
819
+ state.environments[state.activeEnvIdx].baseUrl = e.target.value;
820
+ }
821
+ _saveEnvs();
822
+ renderDetailOnly();
823
+ });
824
+ }
825
+
826
+ const tokenInput = document.getElementById('env-token');
827
+ if (tokenInput) {
828
+ tokenInput.addEventListener('change', e => {
829
+ state.env.token = e.target.value;
830
+ if (state.environments[state.activeEnvIdx]) {
831
+ state.environments[state.activeEnvIdx].token = e.target.value;
832
+ }
833
+ _saveEnvs();
834
+ });
835
+ }
836
+
837
+ const exportPostman = document.getElementById('export-postman');
838
+ if (exportPostman) {
839
+ exportPostman.addEventListener('click', () => {
840
+ window.open(`${PREFIX}/_api/export/postman?baseUrl=${encodeURIComponent(state.env.baseUrl)}`, '_blank');
841
+ });
842
+ }
843
+
844
+ const exportOpenApi = document.getElementById('export-openapi');
845
+ if (exportOpenApi) {
846
+ exportOpenApi.addEventListener('click', () => {
847
+ window.open(`${PREFIX}/_api/export/openapi?baseUrl=${encodeURIComponent(state.env.baseUrl)}`, '_blank');
848
+ });
849
+ }
850
+ }
851
+
852
+ // ── Send request ───────────────────────────────────────────────────────────────
853
+
854
+ async function sendRequest() {
855
+ const ep = getActiveEp();
856
+ if (!ep) return;
857
+
858
+ const tryUrl = document.getElementById('try-url');
859
+ const url = tryUrl ? tryUrl.value : buildUrl(ep);
860
+ const body = collectBody(ep);
861
+ const headers = {};
862
+ if (ep.auth && state.env.token) {
863
+ headers['Authorization'] = `Bearer ${state.env.token}`;
864
+ }
865
+
866
+ state.trying = true;
867
+ state.response = null;
868
+ renderDetailOnly();
869
+
870
+ try {
871
+ const r = await fetch(`${PREFIX}/_api/try`, {
872
+ method: 'POST',
873
+ headers: { 'Content-Type': 'application/json' },
874
+ body: JSON.stringify({ method: ep.verb.toUpperCase(), url, headers, body, encoding: ep.bodyEncoding }),
875
+ });
876
+ const j = await r.json();
877
+ state.response = j.ok ? j : { status: 0, body: j.error, time: 0 };
878
+ } catch (err) {
879
+ state.response = { status: 0, body: err.message, time: 0 };
880
+ }
881
+
882
+ state.trying = false;
883
+
884
+ // Show toast on error status
885
+ if (state.response && state.response.status >= 400) {
886
+ UI.Toast.show(`${state.response.status} — ${_statusText(state.response.status)}`, 'error', 3000);
887
+ } else if (state.response && state.response.status > 0) {
888
+ UI.Toast.show(`${state.response.status} ${_statusText(state.response.status)}`, 'success', 2000);
889
+ } else if (state.response && state.response.status === 0) {
890
+ UI.Toast.show('Request failed — check console', 'error', 4000);
891
+ }
892
+
893
+ // Push to history (keep last 20)
894
+ if (state.response) {
895
+ const ep = getActiveEp();
896
+ state.history.unshift({
897
+ verb: ep ? ep.verb : '?',
898
+ path: ep ? ep.path : url,
899
+ label: ep ? ep.label : url,
900
+ url,
901
+ status: state.response.status,
902
+ time: state.response.time,
903
+ body: state.response.body,
904
+ headers: state.response.headers,
905
+ ts: new Date().toLocaleTimeString(),
906
+ });
907
+ if (state.history.length > 20) state.history.pop();
908
+ }
909
+
910
+ renderDetailOnly();
911
+ }
912
+
913
+ function renderDetailOnly() {
914
+ const detail = document.querySelector('.detail');
915
+ if (!detail) return;
916
+ detail.innerHTML = renderDetail();
917
+ bindDetailEvents();
918
+ }
919
+
920
+ function bindDetailEvents() {
921
+ const tryBtn = document.getElementById('try-btn');
922
+ if (tryBtn) tryBtn.addEventListener('click', sendRequest);
923
+
924
+ const copyBtn = document.getElementById('copy-response');
925
+ if (copyBtn) {
926
+ copyBtn.addEventListener('click', () => {
927
+ const pre = document.getElementById('response-body');
928
+ if (pre) {
929
+ navigator.clipboard.writeText(pre.textContent);
930
+ UI.Toast.show('Response copied to clipboard', 'success', 2000);
931
+ }
932
+ });
933
+ }
934
+
935
+ document.querySelectorAll('[data-key]').forEach(el => {
936
+ el.addEventListener('input', e => {
937
+ const key = e.target.dataset.key;
938
+ state.fieldValues[key] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
939
+ const ep = getActiveEp();
940
+ if (ep) {
941
+ localStorage.setItem(`fields:${ep.verb}:${ep.path}`, JSON.stringify(state.fieldValues));
942
+ const tryUrl = document.getElementById('try-url');
943
+ if (tryUrl) tryUrl.value = buildUrl(ep);
944
+ }
945
+ });
946
+ });
947
+
948
+ document.querySelectorAll('.tab-btn').forEach(btn => {
949
+ btn.addEventListener('click', () => {
950
+ state.activeTab = btn.dataset.tab;
951
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === state.activeTab));
952
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.dataset.panel === state.activeTab));
953
+ });
954
+ });
955
+
956
+ // Body mode toggle
957
+ document.querySelectorAll('.mode-btn').forEach(btn => {
958
+ btn.addEventListener('click', () => {
959
+ state.bodyMode = btn.dataset.mode;
960
+ renderDetailOnly();
961
+ });
962
+ });
963
+
964
+ // Raw body textarea — save to fieldValues
965
+ const rawInput = document.getElementById('raw-body-input');
966
+ if (rawInput) {
967
+ rawInput.addEventListener('input', e => {
968
+ state.fieldValues[e.target.dataset.key] = e.target.value;
969
+ });
970
+ }
971
+
972
+ // Clear response
973
+ const clearBtn = document.getElementById('clear-response');
974
+ if (clearBtn) {
975
+ clearBtn.addEventListener('click', () => {
976
+ state.response = null;
977
+ renderDetailOnly();
978
+ });
979
+ }
980
+
981
+ // Copy URL button
982
+ const copyUrlBtn = document.getElementById('copy-url-btn');
983
+ if (copyUrlBtn) {
984
+ copyUrlBtn.addEventListener('click', () => {
985
+ const tryUrl = document.getElementById('try-url');
986
+ if (tryUrl) {
987
+ navigator.clipboard.writeText(tryUrl.value);
988
+ UI.Toast.show('URL copied', 'info', 1500);
989
+ }
990
+ });
991
+ }
992
+
993
+ // Enter key in URL bar fires request
994
+ const tryUrl = document.getElementById('try-url');
995
+ if (tryUrl) {
996
+ tryUrl.addEventListener('keydown', e => {
997
+ if (e.key === 'Enter') sendRequest();
998
+ });
999
+ }
1000
+
1001
+ // History toggle
1002
+ const toggleHistory = document.getElementById('toggle-history');
1003
+ if (toggleHistory) {
1004
+ toggleHistory.addEventListener('click', () => {
1005
+ state.showHistory = !state.showHistory;
1006
+ renderDetailOnly();
1007
+ });
1008
+ }
1009
+
1010
+ // Clear history — confirm via UI.Confirm
1011
+ const clearHistory = document.getElementById('clear-history');
1012
+ if (clearHistory) {
1013
+ clearHistory.addEventListener('click', () => {
1014
+ UI.Confirm.show({
1015
+ title: 'Clear history?',
1016
+ message: 'All recorded requests will be removed.',
1017
+ confirm: 'Clear',
1018
+ cancel: 'Keep',
1019
+ danger: true,
1020
+ }).then(ok => {
1021
+ if (!ok) return;
1022
+ state.history = [];
1023
+ state.showHistory = false;
1024
+ renderDetailOnly();
1025
+ UI.Toast.show('History cleared', 'info', 2000);
1026
+ });
1027
+ });
1028
+ }
1029
+
1030
+ // Click history row — restore response
1031
+ document.querySelectorAll('.history-row').forEach(row => {
1032
+ row.addEventListener('click', () => {
1033
+ const idx = parseInt(row.dataset.idx);
1034
+ const h = state.history[idx];
1035
+ if (h) {
1036
+ state.response = { status: h.status, body: h.body, headers: h.headers, time: h.time };
1037
+ renderDetailOnly();
1038
+ }
1039
+ });
1040
+ });
1041
+ }
1042
+
1043
+ // ── Helpers ───────────────────────────────────────────────────────────────────
1044
+
1045
+ function getActiveEp() {
1046
+ if (!state.activeEp || !state.manifest) return null;
1047
+ for (const g of state.manifest.groups) {
1048
+ if (g.slug !== state.activeEp.group) continue;
1049
+ for (const ep of g.endpoints) {
1050
+ if (ep.path + ep.verb === state.activeEp.ep) return ep;
1051
+ }
1052
+ }
1053
+ return null;
1054
+ }
1055
+
1056
+ function buildUrl(ep) {
1057
+ let url = (state.env.baseUrl || '').replace(/\/$/, '') + ep.path;
1058
+ // Substitute path params
1059
+ url = url.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
1060
+ const key = `param:${name}`;
1061
+ return state.fieldValues[key] || `:${name}`;
1062
+ });
1063
+ // Append query params
1064
+ const qp = ep.query || {};
1065
+ const qs = Object.entries(qp)
1066
+ .map(([k]) => {
1067
+ const val = state.fieldValues[`query:${k}`];
1068
+ return val !== undefined && val !== '' ? `${encodeURIComponent(k)}=${encodeURIComponent(val)}` : null;
1069
+ })
1070
+ .filter(Boolean)
1071
+ .join('&');
1072
+ if (qs) url += '?' + qs;
1073
+ return url;
1074
+ }
1075
+
1076
+ function collectBody(ep) {
1077
+ const body = ep.body || {};
1078
+ if (!Object.keys(body).length || ep.verb === 'get') return null;
1079
+
1080
+ // Raw JSON mode — parse directly from textarea
1081
+ if (state.bodyMode === 'raw') {
1082
+ const rawKey = `raw:${ep.verb}:${ep.path}`;
1083
+ const raw = state.fieldValues[rawKey] || '';
1084
+ try { return raw ? JSON.parse(raw) : null; } catch { return raw || null; }
1085
+ }
1086
+
1087
+ // Form mode
1088
+ const out = {};
1089
+ for (const [k, def] of Object.entries(body)) {
1090
+ const val = state.fieldValues[`body:${k}`];
1091
+ if (val !== undefined && val !== '') {
1092
+ if (def.type === 'number' || def.type === 'integer') out[k] = Number(val);
1093
+ else if (def.type === 'boolean') out[k] = val === true || val === 'true';
1094
+ else if (def.type === 'json') { try { out[k] = JSON.parse(val); } catch { out[k] = val; } }
1095
+ else if (def.type === 'array') { try { out[k] = JSON.parse(val); } catch { out[k] = val; } }
1096
+ else out[k] = val;
1097
+ } else if (def.example !== undefined) {
1098
+ out[k] = def.example;
1099
+ }
1100
+ }
1101
+ return Object.keys(out).length ? out : null;
1102
+ }
1103
+
1104
+ function filterGroups(groups, search) {
1105
+ if (!search) return groups;
1106
+ const q = search.toLowerCase();
1107
+ return groups
1108
+ .map(g => ({
1109
+ ...g,
1110
+ endpoints: g.endpoints.filter(ep =>
1111
+ ep.label.toLowerCase().includes(q) ||
1112
+ ep.path.toLowerCase().includes(q) ||
1113
+ ep.verb.includes(q)
1114
+ ),
1115
+ }))
1116
+ .filter(g => g.endpoints.length);
1117
+ }
1118
+
1119
+ function countEndpoints(m) {
1120
+ return m.groups.reduce((s, g) => s + g.endpoints.length, 0);
1121
+ }
1122
+
1123
+ function _esc(s) {
1124
+ return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1125
+ }
1126
+
1127
+ function _statusClass(s) {
1128
+ if (s >= 200 && s < 300) return 'status-2xx';
1129
+ if (s >= 300 && s < 400) return 'status-3xx';
1130
+ if (s >= 400 && s < 500) return 'status-4xx';
1131
+ return 'status-5xx';
1132
+ }
1133
+
1134
+ function _statusText(s) {
1135
+ const t = {200:'OK',201:'Created',204:'No Content',400:'Bad Request',401:'Unauthorized',403:'Forbidden',404:'Not Found',422:'Unprocessable Entity',429:'Too Many Requests',500:'Server Error'};
1136
+ return t[s] || '';
1137
+ }
1138
+
1139
+
1140
+ // ── Syntax highlighting ────────────────────────────────────────────────────────
1141
+ function _syntaxHighlight(str) {
1142
+ if (!str) return '';
1143
+ // Try to pretty-print if it's JSON
1144
+ try {
1145
+ const parsed = JSON.parse(str);
1146
+ str = JSON.stringify(parsed, null, 2);
1147
+ } catch {}
1148
+
1149
+ // Escape HTML first
1150
+ str = str
1151
+ .replace(/&/g, '&amp;')
1152
+ .replace(/</g, '&lt;')
1153
+ .replace(/>/g, '&gt;');
1154
+
1155
+ // Colorize JSON tokens
1156
+ return str.replace(
1157
+ /("(\u[a-zA-Z0-9]{4}|\[^u]|[^\"])*"(\s*:)?|(true|false|null)|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
1158
+ (match) => {
1159
+ let cls = 'syn-number';
1160
+ if (/^"/.test(match)) {
1161
+ cls = /:$/.test(match) ? 'syn-key' : 'syn-string';
1162
+ } else if (/true|false/.test(match)) {
1163
+ cls = 'syn-bool';
1164
+ } else if (/null/.test(match)) {
1165
+ cls = 'syn-null';
1166
+ }
1167
+ return `<span class="${cls}">${match}</span>`;
1168
+ }
1169
+ );
1170
+ }
1171
+
1172
+ // ── Start ─────────────────────────────────────────────────────────────────────
1173
+ // Wait for UI to be available (loaded by shell before docs.js)
1174
+ (function waitForUI() {
1175
+ if (typeof UI !== 'undefined') {
1176
+ init();
1177
+ } else {
1178
+ // ui.js not yet loaded (edge case) — retry
1179
+ setTimeout(waitForUI, 10);
1180
+ }
1181
+ })();