millas 0.2.13 → 0.2.15
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/package.json +6 -3
- package/src/admin/Admin.js +107 -1027
- package/src/admin/AdminAuth.js +1 -1
- package/src/admin/ViewContext.js +1 -1
- package/src/admin/handlers/ActionHandler.js +103 -0
- package/src/admin/handlers/ApiHandler.js +113 -0
- package/src/admin/handlers/AuthHandler.js +76 -0
- package/src/admin/handlers/ExportHandler.js +70 -0
- package/src/admin/handlers/InlineHandler.js +71 -0
- package/src/admin/handlers/PageHandler.js +351 -0
- package/src/admin/resources/AdminResource.js +22 -1
- package/src/admin/static/SelectFilter2.js +34 -0
- package/src/admin/static/actions.js +201 -0
- package/src/admin/static/admin.css +7 -0
- package/src/admin/static/change_form.js +585 -0
- package/src/admin/static/core.js +128 -0
- package/src/admin/static/login.js +76 -0
- package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
- package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
- package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
- package/src/admin/static/vendor/jquery.min.js +2 -0
- package/src/admin/views/layouts/base.njk +30 -113
- package/src/admin/views/pages/detail.njk +10 -9
- package/src/admin/views/pages/form.njk +4 -4
- package/src/admin/views/pages/list.njk +11 -193
- package/src/admin/views/pages/login.njk +19 -64
- package/src/admin/views/partials/form-field.njk +1 -1
- package/src/admin/views/partials/form-scripts.njk +4 -478
- package/src/admin/views/partials/form-widget.njk +10 -10
- package/src/ai/AITokenBudget.js +1 -1
- package/src/auth/Auth.js +112 -3
- package/src/auth/AuthMiddleware.js +18 -15
- package/src/auth/Hasher.js +15 -43
- package/src/cli.js +3 -0
- package/src/commands/call.js +190 -0
- package/src/commands/createsuperuser.js +3 -4
- package/src/commands/key.js +97 -0
- package/src/commands/make.js +16 -2
- package/src/commands/new.js +16 -1
- package/src/commands/serve.js +5 -5
- package/src/console/Command.js +337 -0
- package/src/console/CommandLoader.js +165 -0
- package/src/console/index.js +6 -0
- package/src/container/AppInitializer.js +48 -1
- package/src/container/Application.js +3 -1
- package/src/container/HttpServer.js +0 -1
- package/src/container/MillasConfig.js +48 -0
- package/src/controller/Controller.js +13 -11
- package/src/core/docs.js +6 -0
- package/src/core/foundation.js +8 -0
- package/src/core/http.js +20 -10
- package/src/core/validation.js +58 -27
- package/src/docs/Docs.js +268 -0
- package/src/docs/DocsServiceProvider.js +80 -0
- package/src/docs/SchemaInferrer.js +131 -0
- package/src/docs/handlers/ApiHandler.js +305 -0
- package/src/docs/handlers/PageHandler.js +47 -0
- package/src/docs/index.js +13 -0
- package/src/docs/resources/ApiResource.js +402 -0
- package/src/docs/static/docs.css +723 -0
- package/src/docs/static/docs.js +1181 -0
- package/src/encryption/Encrypter.js +381 -0
- package/src/facades/Auth.js +5 -2
- package/src/facades/Crypt.js +166 -0
- package/src/facades/Docs.js +43 -0
- package/src/facades/Mail.js +1 -1
- package/src/http/MillasRequest.js +7 -31
- package/src/http/RequestContext.js +11 -7
- package/src/http/SecurityBootstrap.js +24 -2
- package/src/http/Shape.js +168 -0
- package/src/http/adapters/ExpressAdapter.js +9 -5
- package/src/middleware/CorsMiddleware.js +3 -0
- package/src/middleware/ThrottleMiddleware.js +10 -7
- package/src/orm/model/Model.js +20 -2
- package/src/providers/EncryptionServiceProvider.js +66 -0
- package/src/router/MiddlewareRegistry.js +79 -54
- package/src/router/Route.js +9 -4
- package/src/router/RouteEntry.js +91 -0
- package/src/router/Router.js +71 -1
- package/src/scaffold/maker.js +138 -1
- package/src/scaffold/templates.js +12 -0
- package/src/serializer/Serializer.js +239 -0
- package/src/support/Str.js +1080 -0
- package/src/validation/BaseValidator.js +45 -5
- package/src/validation/Validator.js +67 -61
- package/src/validation/types.js +490 -0
- package/src/middleware/AuthMiddleware.js +0 -46
- 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 · ${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 · ${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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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, '&')
|
|
1152
|
+
.replace(/</g, '<')
|
|
1153
|
+
.replace(/>/g, '>');
|
|
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
|
+
})();
|