@luckystack/docs-ui 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -14
- package/CLAUDE.md +55 -55
- package/LICENSE +21 -21
- package/README.md +86 -86
- package/dist/chunk-IL5ERE5J.js +578 -0
- package/dist/chunk-IL5ERE5J.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -447
- package/dist/index.js.map +1 -1
- package/dist/register.d.ts +2 -0
- package/dist/register.js +8 -0
- package/dist/register.js.map +1 -0
- package/docs/html-generation.md +9 -6
- package/package.json +16 -3
package/dist/index.d.ts
CHANGED
|
@@ -58,6 +58,13 @@ interface MountDocsUiOptions {
|
|
|
58
58
|
* runner needs a logged-in session.
|
|
59
59
|
*/
|
|
60
60
|
enableTryItOut?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Optional per-request authorization hook. Called after the env/bind-address
|
|
63
|
+
* gate passes. Return `true` to allow the request, `false` to serve 403.
|
|
64
|
+
* Use to restrict docs access to authenticated or IP-allowlisted callers on
|
|
65
|
+
* non-loopback deployments.
|
|
66
|
+
*/
|
|
67
|
+
authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
|
|
61
68
|
}
|
|
62
69
|
type DocsRouteHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
|
63
70
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,450 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// src/docsHtml.ts
|
|
6
|
-
var renderDocsHtml = (jsonPath, pageTitle, options = {}) => {
|
|
7
|
-
const branding = options.branding ?? {};
|
|
8
|
-
const accent = branding.brandColor ?? "#58a6ff";
|
|
9
|
-
const fontFamily = branding.fontFamily ?? `system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`;
|
|
10
|
-
const logoMarkup = branding.logoUrl ? `<img src="${escapeHtml(branding.logoUrl)}" alt="logo" style="height:32px;width:auto;margin-right:12px;" />` : "";
|
|
11
|
-
const tryItOutData = options.enableTryItOut ? "true" : "false";
|
|
12
|
-
return `<!DOCTYPE html>
|
|
13
|
-
<html lang="en">
|
|
14
|
-
<head>
|
|
15
|
-
<meta charset="utf-8" />
|
|
16
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
17
|
-
<title>${escapeHtml(pageTitle)}</title>
|
|
18
|
-
<style>
|
|
19
|
-
:root {
|
|
20
|
-
--bg: #0e1116;
|
|
21
|
-
--container: #161b22;
|
|
22
|
-
--container-hover: #1c232b;
|
|
23
|
-
--border: #2a313c;
|
|
24
|
-
--title: #e6edf3;
|
|
25
|
-
--common: #c9d1d9;
|
|
26
|
-
--muted: #8b949e;
|
|
27
|
-
--accent: ${accent};
|
|
28
|
-
--font-family: ${fontFamily};
|
|
29
|
-
--get: #3fb950;
|
|
30
|
-
--post: ${accent};
|
|
31
|
-
--put: #d29922;
|
|
32
|
-
--delete: #f85149;
|
|
33
|
-
}
|
|
34
|
-
@media (prefers-color-scheme: light) {
|
|
35
|
-
:root {
|
|
36
|
-
--bg: #ffffff;
|
|
37
|
-
--container: #f6f8fa;
|
|
38
|
-
--container-hover: #eaeef2;
|
|
39
|
-
--border: #d0d7de;
|
|
40
|
-
--title: #1f2328;
|
|
41
|
-
--common: #1f2328;
|
|
42
|
-
--muted: #59636e;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
* { box-sizing: border-box; }
|
|
46
|
-
body {
|
|
47
|
-
margin: 0; padding: 0;
|
|
48
|
-
font-family: var(--font-family);
|
|
49
|
-
background: var(--bg); color: var(--common);
|
|
50
|
-
line-height: 1.5;
|
|
51
|
-
}
|
|
52
|
-
.brand-row { display: flex; align-items: center; }
|
|
53
|
-
.try-it-out { margin-top: 12px; padding: 12px; background: var(--container-hover); border: 1px solid var(--border); border-radius: 6px; }
|
|
54
|
-
.try-it-out textarea { width: 100%; min-height: 80px; background: var(--bg); color: var(--common); border: 1px solid var(--border); padding: 6px; font-family: ui-monospace, monospace; font-size: 12px; }
|
|
55
|
-
.try-it-out button { margin-top: 6px; padding: 6px 12px; background: var(--accent); color: white; border: 0; border-radius: 4px; cursor: pointer; }
|
|
56
|
-
.try-it-out pre.result { margin-top: 8px; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; max-height: 240px; overflow: auto; font-size: 12px; }
|
|
57
|
-
.layout {
|
|
58
|
-
max-width: 1200px;
|
|
59
|
-
margin: 0 auto;
|
|
60
|
-
padding: 32px 24px 96px;
|
|
61
|
-
}
|
|
62
|
-
h1 { color: var(--title); margin: 0 0 4px; font-size: 28px; }
|
|
63
|
-
.lead { color: var(--muted); margin: 0 0 32px; font-size: 14px; }
|
|
64
|
-
.summary { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
|
|
65
|
-
.summary-pill {
|
|
66
|
-
background: var(--container);
|
|
67
|
-
border: 1px solid var(--border);
|
|
68
|
-
padding: 8px 14px;
|
|
69
|
-
border-radius: 8px;
|
|
70
|
-
font-size: 13px;
|
|
71
|
-
}
|
|
72
|
-
.summary-pill strong { color: var(--title); }
|
|
73
|
-
.filter-bar {
|
|
74
|
-
margin-bottom: 24px;
|
|
75
|
-
display: flex;
|
|
76
|
-
gap: 12px;
|
|
77
|
-
align-items: center;
|
|
78
|
-
}
|
|
79
|
-
input[type="search"] {
|
|
80
|
-
flex: 1;
|
|
81
|
-
background: var(--container);
|
|
82
|
-
border: 1px solid var(--border);
|
|
83
|
-
color: var(--common);
|
|
84
|
-
padding: 10px 14px;
|
|
85
|
-
border-radius: 8px;
|
|
86
|
-
font-size: 14px;
|
|
87
|
-
font-family: inherit;
|
|
88
|
-
}
|
|
89
|
-
input[type="search"]:focus {
|
|
90
|
-
outline: none;
|
|
91
|
-
border-color: var(--accent);
|
|
92
|
-
}
|
|
93
|
-
.group {
|
|
94
|
-
margin-bottom: 24px;
|
|
95
|
-
background: var(--container);
|
|
96
|
-
border: 1px solid var(--border);
|
|
97
|
-
border-radius: 12px;
|
|
98
|
-
overflow: hidden;
|
|
99
|
-
}
|
|
100
|
-
.group-header {
|
|
101
|
-
padding: 12px 18px;
|
|
102
|
-
background: var(--container-hover);
|
|
103
|
-
font-size: 13px;
|
|
104
|
-
font-weight: 600;
|
|
105
|
-
color: var(--muted);
|
|
106
|
-
text-transform: uppercase;
|
|
107
|
-
letter-spacing: 0.04em;
|
|
108
|
-
border-bottom: 1px solid var(--border);
|
|
109
|
-
}
|
|
110
|
-
.endpoint {
|
|
111
|
-
padding: 14px 18px;
|
|
112
|
-
border-bottom: 1px solid var(--border);
|
|
113
|
-
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;
|
|
114
|
-
font-size: 13px;
|
|
115
|
-
cursor: pointer;
|
|
116
|
-
transition: background 80ms;
|
|
117
|
-
}
|
|
118
|
-
.endpoint:last-child { border-bottom: none; }
|
|
119
|
-
.endpoint:hover { background: var(--container-hover); }
|
|
120
|
-
.endpoint-summary { display: flex; align-items: center; gap: 12px; }
|
|
121
|
-
.method {
|
|
122
|
-
display: inline-block;
|
|
123
|
-
min-width: 56px;
|
|
124
|
-
padding: 2px 8px;
|
|
125
|
-
border-radius: 4px;
|
|
126
|
-
font-size: 11px;
|
|
127
|
-
font-weight: 700;
|
|
128
|
-
text-align: center;
|
|
129
|
-
color: #fff;
|
|
130
|
-
}
|
|
131
|
-
.method.GET { background: var(--get); }
|
|
132
|
-
.method.POST { background: var(--post); }
|
|
133
|
-
.method.PUT { background: var(--put); color: #1f2328; }
|
|
134
|
-
.method.DELETE { background: var(--delete); }
|
|
135
|
-
.endpoint-name { color: var(--title); font-weight: 500; }
|
|
136
|
-
.endpoint-meta { margin-left: auto; color: var(--muted); font-size: 12px; }
|
|
137
|
-
.endpoint-detail {
|
|
138
|
-
margin-top: 12px;
|
|
139
|
-
padding: 12px;
|
|
140
|
-
background: var(--bg);
|
|
141
|
-
border-radius: 8px;
|
|
142
|
-
border: 1px solid var(--border);
|
|
143
|
-
display: none;
|
|
144
|
-
white-space: pre-wrap;
|
|
145
|
-
word-break: break-word;
|
|
146
|
-
}
|
|
147
|
-
.endpoint.open .endpoint-detail { display: block; }
|
|
148
|
-
.detail-section { margin-bottom: 12px; }
|
|
149
|
-
.detail-section:last-child { margin-bottom: 0; }
|
|
150
|
-
.detail-label {
|
|
151
|
-
color: var(--muted);
|
|
152
|
-
font-size: 11px;
|
|
153
|
-
text-transform: uppercase;
|
|
154
|
-
letter-spacing: 0.04em;
|
|
155
|
-
margin-bottom: 4px;
|
|
156
|
-
}
|
|
157
|
-
pre {
|
|
158
|
-
margin: 0;
|
|
159
|
-
color: var(--common);
|
|
160
|
-
font-size: 12px;
|
|
161
|
-
overflow-x: auto;
|
|
162
|
-
}
|
|
163
|
-
.auth-tag {
|
|
164
|
-
display: inline-block;
|
|
165
|
-
padding: 1px 6px;
|
|
166
|
-
border-radius: 4px;
|
|
167
|
-
background: var(--container-hover);
|
|
168
|
-
color: var(--muted);
|
|
169
|
-
font-size: 11px;
|
|
170
|
-
margin-right: 4px;
|
|
171
|
-
}
|
|
172
|
-
.empty {
|
|
173
|
-
padding: 48px;
|
|
174
|
-
text-align: center;
|
|
175
|
-
color: var(--muted);
|
|
176
|
-
}
|
|
177
|
-
a.json-link { color: var(--accent); }
|
|
178
|
-
</style>
|
|
179
|
-
</head>
|
|
180
|
-
<body>
|
|
181
|
-
<div class="layout">
|
|
182
|
-
<div class="brand-row">${logoMarkup}<h1>${escapeHtml(pageTitle)}</h1></div>
|
|
183
|
-
<p class="lead">Generated from <code>apiDocs.generated.json</code>. Raw JSON: <a class="json-link" href="${escapeHtml(jsonPath)}">${escapeHtml(jsonPath)}</a></p>
|
|
184
|
-
<div class="summary" id="summary"></div>
|
|
185
|
-
<div class="filter-bar">
|
|
186
|
-
<input type="search" id="filter" placeholder="Filter by route name\u2026" autofocus />
|
|
187
|
-
</div>
|
|
188
|
-
<div id="content"></div>
|
|
189
|
-
</div>
|
|
190
|
-
<script>
|
|
191
|
-
const JSON_PATH = ${JSON.stringify(jsonPath)};
|
|
192
|
-
const ENABLE_TRY_IT_OUT = ${tryItOutData};
|
|
193
|
-
const stateByKey = new Map();
|
|
194
|
-
|
|
195
|
-
//? Inline runner: hits the live server using the same fetch transport
|
|
196
|
-
//? that the framework apiRequest helper uses. Auth comes from the
|
|
197
|
-
//? browser's existing session cookie or sessionStorage; no token prompt.
|
|
198
|
-
const runEndpoint = async (button, route, version, dataField) => {
|
|
199
|
-
let parsed;
|
|
200
|
-
try {
|
|
201
|
-
parsed = dataField.value.trim().length === 0 ? {} : JSON.parse(dataField.value);
|
|
202
|
-
} catch (err) {
|
|
203
|
-
const resultEl = button.parentElement.querySelector('pre.result');
|
|
204
|
-
resultEl.textContent = 'JSON parse error: ' + err.message;
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
button.disabled = true;
|
|
208
|
-
const resultEl = button.parentElement.querySelector('pre.result');
|
|
209
|
-
resultEl.textContent = 'Sending...';
|
|
210
|
-
try {
|
|
211
|
-
const response = await fetch('/' + route + '?stream=false', {
|
|
212
|
-
method: 'POST',
|
|
213
|
-
headers: { 'Content-Type': 'application/json' },
|
|
214
|
-
credentials: 'include',
|
|
215
|
-
body: JSON.stringify(parsed),
|
|
216
|
-
});
|
|
217
|
-
const text = await response.text();
|
|
218
|
-
try {
|
|
219
|
-
resultEl.textContent = JSON.stringify(JSON.parse(text), null, 2);
|
|
220
|
-
} catch {
|
|
221
|
-
resultEl.textContent = text;
|
|
222
|
-
}
|
|
223
|
-
} catch (err) {
|
|
224
|
-
resultEl.textContent = 'Network error: ' + err.message;
|
|
225
|
-
} finally {
|
|
226
|
-
button.disabled = false;
|
|
227
|
-
}
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
const renderTryItOut = (route, version) => {
|
|
231
|
-
if (!ENABLE_TRY_IT_OUT) return '';
|
|
232
|
-
return '<div class="try-it-out">' +
|
|
233
|
-
'<div style="font-weight:600;font-size:12px;margin-bottom:4px;">Try it out (live request)</div>' +
|
|
234
|
-
'<textarea placeholder=\\'{"key":"value"}\\'>{}</textarea>' +
|
|
235
|
-
'<button onclick="runEndpoint(this,\\'' + route + '\\',\\'' + version + '\\',this.previousElementSibling)">Send</button>' +
|
|
236
|
-
'<pre class="result"></pre>' +
|
|
237
|
-
'</div>';
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
const escapeHtml = (str) => String(str).replace(/[&<>"']/g, (c) => ({
|
|
241
|
-
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
242
|
-
})[c]);
|
|
243
|
-
|
|
244
|
-
const renderAuth = (auth) => {
|
|
245
|
-
if (!auth) return '<span class="auth-tag">public</span>';
|
|
246
|
-
const tags = [];
|
|
247
|
-
if (auth.login) tags.push('login required');
|
|
248
|
-
if (Array.isArray(auth.additional)) {
|
|
249
|
-
for (const rule of auth.additional) {
|
|
250
|
-
if (rule.key) tags.push(rule.key + (rule.value !== undefined ? '=' + JSON.stringify(rule.value) : ''));
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
if (tags.length === 0) tags.push('public');
|
|
254
|
-
return tags.map(t => '<span class="auth-tag">' + escapeHtml(t) + '</span>').join('');
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
const renderEndpoint = (page, name, version, meta) => {
|
|
258
|
-
const key = page + '/' + name + '@' + version;
|
|
259
|
-
const method = (meta.method || 'POST').toUpperCase();
|
|
260
|
-
const rate = meta.rateLimit;
|
|
261
|
-
const rateLabel = rate === false ? 'no rate limit' : rate === undefined ? 'default rate' : (rate + '/min');
|
|
262
|
-
const isOpen = stateByKey.get(key) === true;
|
|
263
|
-
const path = '/api/' + page + '/' + name + '/' + version;
|
|
264
|
-
return \`
|
|
265
|
-
<div class="endpoint \${isOpen ? 'open' : ''}" data-key="\${escapeHtml(key)}">
|
|
266
|
-
<div class="endpoint-summary">
|
|
267
|
-
<span class="method \${method}">\${method}</span>
|
|
268
|
-
<span class="endpoint-name">\${escapeHtml(path)}</span>
|
|
269
|
-
<span class="endpoint-meta">\${escapeHtml(rateLabel)}</span>
|
|
270
|
-
</div>
|
|
271
|
-
<div class="endpoint-detail">
|
|
272
|
-
<div class="detail-section">
|
|
273
|
-
<div class="detail-label">auth</div>
|
|
274
|
-
<div>\${renderAuth(meta.auth)}</div>
|
|
275
|
-
</div>
|
|
276
|
-
<div class="detail-section">
|
|
277
|
-
<div class="detail-label">input</div>
|
|
278
|
-
<pre>\${escapeHtml(meta.input || '{}')}</pre>
|
|
279
|
-
</div>
|
|
280
|
-
<div class="detail-section">
|
|
281
|
-
<div class="detail-label">output</div>
|
|
282
|
-
<pre>\${escapeHtml(meta.output || 'unknown')}</pre>
|
|
283
|
-
</div>
|
|
284
|
-
\${meta.stream ? \`
|
|
285
|
-
<div class="detail-section">
|
|
286
|
-
<div class="detail-label">stream</div>
|
|
287
|
-
<pre>\${escapeHtml(meta.stream)}</pre>
|
|
288
|
-
</div>
|
|
289
|
-
\` : ''}
|
|
290
|
-
\${meta.owner ? \`
|
|
291
|
-
<div class="detail-section">
|
|
292
|
-
<div class="detail-label">owner</div>
|
|
293
|
-
<div>\${escapeHtml(meta.owner)}</div>
|
|
294
|
-
</div>
|
|
295
|
-
\` : ''}
|
|
296
|
-
\${meta.tags && meta.tags.length ? \`
|
|
297
|
-
<div class="detail-section">
|
|
298
|
-
<div class="detail-label">tags</div>
|
|
299
|
-
<div>\${meta.tags.map(t => '<span class="badge">' + escapeHtml(String(t)) + '</span>').join(' ')}</div>
|
|
300
|
-
</div>
|
|
301
|
-
\` : ''}
|
|
302
|
-
\${meta.deprecated ? \`
|
|
303
|
-
<div class="detail-section">
|
|
304
|
-
<div class="detail-label">deprecated</div>
|
|
305
|
-
<div style="color:var(--delete);">\${escapeHtml(String(meta.deprecated))}</div>
|
|
306
|
-
</div>
|
|
307
|
-
\` : ''}
|
|
308
|
-
\${renderTryItOut(page + '/' + name + '/' + version, version)}
|
|
309
|
-
</div>
|
|
310
|
-
</div>
|
|
311
|
-
\`;
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
const passesFilter = (filter, page, name) => {
|
|
315
|
-
if (!filter) return true;
|
|
316
|
-
const haystack = (page + '/' + name).toLowerCase();
|
|
317
|
-
return haystack.includes(filter.toLowerCase());
|
|
318
|
-
};
|
|
319
|
-
|
|
320
|
-
const render = (data, filter) => {
|
|
321
|
-
const summary = document.getElementById('summary');
|
|
322
|
-
const content = document.getElementById('content');
|
|
323
|
-
const apis = data && data.apis ? data.apis : data;
|
|
324
|
-
if (!apis || typeof apis !== 'object') {
|
|
325
|
-
content.innerHTML = '<div class="empty">No API docs available. Run <code>npm run generateArtifacts</code> to generate them.</div>';
|
|
326
|
-
summary.innerHTML = '';
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
let total = 0;
|
|
331
|
-
let visible = 0;
|
|
332
|
-
const groups = [];
|
|
333
|
-
for (const [page, names] of Object.entries(apis)) {
|
|
334
|
-
const rows = [];
|
|
335
|
-
for (const [name, versions] of Object.entries(names)) {
|
|
336
|
-
for (const [version, meta] of Object.entries(versions)) {
|
|
337
|
-
total++;
|
|
338
|
-
if (!passesFilter(filter, page, name)) continue;
|
|
339
|
-
visible++;
|
|
340
|
-
rows.push(renderEndpoint(page, name, version, meta || {}));
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
if (rows.length > 0) {
|
|
344
|
-
groups.push(\`
|
|
345
|
-
<div class="group">
|
|
346
|
-
<div class="group-header">\${escapeHtml(page)}</div>
|
|
347
|
-
\${rows.join('')}
|
|
348
|
-
</div>
|
|
349
|
-
\`);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
summary.innerHTML = \`
|
|
354
|
-
<div class="summary-pill"><strong>\${visible}</strong> of <strong>\${total}</strong> endpoints</div>
|
|
355
|
-
<div class="summary-pill"><strong>\${Object.keys(apis).length}</strong> pages</div>
|
|
356
|
-
\`;
|
|
357
|
-
content.innerHTML = groups.join('') || '<div class="empty">No matches.</div>';
|
|
358
|
-
|
|
359
|
-
document.querySelectorAll('.endpoint').forEach((el) => {
|
|
360
|
-
el.addEventListener('click', () => {
|
|
361
|
-
const key = el.getAttribute('data-key');
|
|
362
|
-
const next = !el.classList.contains('open');
|
|
363
|
-
stateByKey.set(key, next);
|
|
364
|
-
el.classList.toggle('open');
|
|
365
|
-
});
|
|
366
|
-
});
|
|
367
|
-
};
|
|
368
|
-
|
|
369
|
-
let cachedData = null;
|
|
370
|
-
document.getElementById('filter').addEventListener('input', (e) => {
|
|
371
|
-
if (cachedData) render(cachedData, e.target.value);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
fetch(JSON_PATH, { credentials: 'include' })
|
|
375
|
-
.then(r => r.ok ? r.json() : Promise.reject(new Error(String(r.status))))
|
|
376
|
-
.then(data => { cachedData = data; render(data, ''); })
|
|
377
|
-
.catch(err => {
|
|
378
|
-
document.getElementById('content').innerHTML =
|
|
379
|
-
'<div class="empty">Could not load API docs: ' + escapeHtml(err.message) + '</div>';
|
|
380
|
-
});
|
|
381
|
-
</script>
|
|
382
|
-
</body>
|
|
383
|
-
</html>`;
|
|
384
|
-
};
|
|
385
|
-
var HTML_ESCAPE_MAP = {
|
|
386
|
-
"&": "&",
|
|
387
|
-
"<": "<",
|
|
388
|
-
">": ">",
|
|
389
|
-
'"': """,
|
|
390
|
-
"'": "'"
|
|
391
|
-
};
|
|
392
|
-
var escapeHtml = (str) => str.replaceAll(/[&<>"']/g, (c) => HTML_ESCAPE_MAP[c] ?? c);
|
|
393
|
-
|
|
394
|
-
// src/index.ts
|
|
395
|
-
var mountDocsUi = (options = {}) => {
|
|
396
|
-
const routePath = options.routePath ?? "/_docs";
|
|
397
|
-
const jsonPath = `${routePath}/api.json`;
|
|
398
|
-
return async (req, res) => {
|
|
399
|
-
const url = req.url ?? "";
|
|
400
|
-
const [pathOnly] = url.split("?");
|
|
401
|
-
if (pathOnly !== routePath && pathOnly !== jsonPath) {
|
|
402
|
-
return false;
|
|
403
|
-
}
|
|
404
|
-
if (process.env.NODE_ENV === "production" && !options.enabledInProd) {
|
|
405
|
-
res.statusCode = 404;
|
|
406
|
-
res.setHeader("Content-Type", "text/plain");
|
|
407
|
-
res.end("Not Found");
|
|
408
|
-
return true;
|
|
409
|
-
}
|
|
410
|
-
if (req.method !== "GET") {
|
|
411
|
-
res.statusCode = 405;
|
|
412
|
-
res.setHeader("Content-Type", "text/plain");
|
|
413
|
-
res.end("Method Not Allowed");
|
|
414
|
-
return true;
|
|
415
|
-
}
|
|
416
|
-
if (pathOnly === jsonPath) {
|
|
417
|
-
const docsPath = options.apiDocsPath ?? getGeneratedApiDocsPath();
|
|
418
|
-
const [readError, content] = await tryCatch(() => fs.readFile(docsPath, "utf8"));
|
|
419
|
-
if (readError) {
|
|
420
|
-
res.statusCode = 404;
|
|
421
|
-
res.setHeader("Content-Type", "application/json");
|
|
422
|
-
res.end(JSON.stringify({
|
|
423
|
-
error: "apiDocs.generated.json not found",
|
|
424
|
-
expectedAt: docsPath,
|
|
425
|
-
hint: "Run `npm run generateArtifacts` to generate it."
|
|
426
|
-
}));
|
|
427
|
-
} else {
|
|
428
|
-
res.statusCode = 200;
|
|
429
|
-
res.setHeader("Content-Type", "application/json");
|
|
430
|
-
res.setHeader("Cache-Control", "no-store");
|
|
431
|
-
res.end(content);
|
|
432
|
-
}
|
|
433
|
-
return true;
|
|
434
|
-
}
|
|
435
|
-
const title = options.pageTitle ?? "LuckyStack \u2014 API docs";
|
|
436
|
-
const branding = options.branding ?? {};
|
|
437
|
-
res.statusCode = 200;
|
|
438
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
439
|
-
res.setHeader("Cache-Control", "no-store");
|
|
440
|
-
const html = options.template ? options.template({ jsonPath, pageTitle: title, branding }) : renderDocsHtml(jsonPath, title, {
|
|
441
|
-
branding,
|
|
442
|
-
enableTryItOut: options.enableTryItOut ?? false
|
|
443
|
-
});
|
|
444
|
-
res.end(html);
|
|
445
|
-
return true;
|
|
446
|
-
};
|
|
447
|
-
};
|
|
1
|
+
import {
|
|
2
|
+
mountDocsUi
|
|
3
|
+
} from "./chunk-IL5ERE5J.js";
|
|
448
4
|
export {
|
|
449
5
|
mountDocsUi
|
|
450
6
|
};
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/docsHtml.ts"],"sourcesContent":["//? Dev-only API docs UI for LuckyStack.\r\n//?\r\n//? Mounts a single page at `/_docs` (configurable) that fetches the\r\n//? framework's `apiDocs.generated.json` and renders every API endpoint\r\n//? grouped by page, with method, auth, rate limit, input shape, and\r\n//? output shape.\r\n//?\r\n//? Usage:\r\n//? // luckystack/docs-ui/index.ts\r\n//? import { mountDocsUi } from '@luckystack/docs-ui';\r\n//? mountDocsUi(); // Auto-disables in production unless { enabledInProd: true }\r\n//?\r\n//? The mount function returns a `customRoutes` handler — pass it to\r\n//? `createLuckyStackServer({ customRoutes })` (or compose with another\r\n//? handler if you have one). It honors the request only when the URL\r\n//? matches the configured `routePath`; everything else falls through.\r\n\r\nimport fs from 'node:fs/promises';\r\nimport type { IncomingMessage, ServerResponse } from 'node:http';\r\nimport { getGeneratedApiDocsPath, tryCatch } from '@luckystack/core';\r\nimport { renderDocsHtml } from './docsHtml';\r\n\r\nexport interface DocsBranding {\r\n /** Logo URL shown in the header. PNG / SVG / data: URLs all accepted. */\r\n logoUrl?: string;\r\n /** CSS color for the header / accent (`#hex` or `rgb()` literal). */\r\n brandColor?: string;\r\n /** Web-safe font family for the page. */\r\n fontFamily?: string;\r\n}\r\n\r\n/**\r\n * Custom HTML builder. Receives the JSON-endpoint path the page should\r\n * fetch from + page title + branding; returns the full HTML document\r\n * (`<!doctype html>` + everything). Use to swap layout entirely (sidebar\r\n * vs tabs, dark/light theme, embedded auth-token field).\r\n */\r\nexport type DocsTemplateBuilder = (input: {\r\n jsonPath: string;\r\n pageTitle: string;\r\n branding: DocsBranding;\r\n}) => string;\r\n\r\nexport interface MountDocsUiOptions {\r\n /** Path the docs UI is served from. Default: `/_docs`. */\r\n routePath?: string;\r\n /**\r\n * Page title shown in the header + browser tab. Defaults to\r\n * \"LuckyStack — API docs\". Pass an explicit string to brand for a consumer.\r\n */\r\n pageTitle?: string;\r\n /**\r\n * Allow rendering the docs UI in production. Default: false. Useful only\r\n * when the docs are intentionally public (developer-portal, internal-only\r\n * deployment, etc.). When false, the route returns 404 in production.\r\n */\r\n enabledInProd?: boolean;\r\n /**\r\n * Override the path to `apiDocs.generated.json`. Defaults to whatever\r\n * `getGeneratedApiDocsPath()` resolves to via ProjectConfig.paths.\r\n */\r\n apiDocsPath?: string;\r\n /**\r\n * Optional branding inputs (logo, brand color, font). Applied by the\r\n * default template. Custom templates may ignore these.\r\n */\r\n branding?: DocsBranding;\r\n /**\r\n * Custom HTML template builder. When provided, the default `renderDocsHtml`\r\n * is bypassed entirely. Use for radically different layouts (sidebar,\r\n * tabs, dark mode, marketing-page-style). Receives a tested JSON path +\r\n * title + branding so your template doesn't need to know about\r\n * `getGeneratedApiDocsPath`.\r\n */\r\n template?: DocsTemplateBuilder;\r\n /**\r\n * Enable the inline \"try-it-out\" runner. When true, the default template\r\n * renders a request-input box + send button per endpoint that calls\r\n * `apiRequest` against the live server. Off by default because the\r\n * runner needs a logged-in session.\r\n */\r\n enableTryItOut?: boolean;\r\n}\r\n\r\nexport type DocsRouteHandler = (\r\n req: IncomingMessage,\r\n res: ServerResponse,\r\n) => Promise<boolean>;\r\n\r\n/**\r\n * Returns a route handler compatible with `createLuckyStackServer`'s\r\n * `customRoutes` option. The handler returns `true` (response sent) when\r\n * the URL matches the configured docs route, `false` otherwise.\r\n */\r\nexport const mountDocsUi = (options: MountDocsUiOptions = {}): DocsRouteHandler => {\r\n const routePath = options.routePath ?? '/_docs';\r\n const jsonPath = `${routePath}/api.json`;\r\n\r\n return async (req, res) => {\r\n const url = req.url ?? '';\r\n const [pathOnly] = url.split('?');\r\n\r\n if (pathOnly !== routePath && pathOnly !== jsonPath) {\r\n return false;\r\n }\r\n\r\n if (process.env.NODE_ENV === 'production' && !options.enabledInProd) {\r\n res.statusCode = 404;\r\n res.setHeader('Content-Type', 'text/plain');\r\n res.end('Not Found');\r\n return true;\r\n }\r\n\r\n if (req.method !== 'GET') {\r\n res.statusCode = 405;\r\n res.setHeader('Content-Type', 'text/plain');\r\n res.end('Method Not Allowed');\r\n return true;\r\n }\r\n\r\n if (pathOnly === jsonPath) {\r\n const docsPath = options.apiDocsPath ?? getGeneratedApiDocsPath();\r\n const [readError, content] = await tryCatch(() => fs.readFile(docsPath, 'utf8'));\r\n if (readError) {\r\n res.statusCode = 404;\r\n res.setHeader('Content-Type', 'application/json');\r\n res.end(JSON.stringify({\r\n error: 'apiDocs.generated.json not found',\r\n expectedAt: docsPath,\r\n hint: 'Run `npm run generateArtifacts` to generate it.',\r\n }));\r\n } else {\r\n res.statusCode = 200;\r\n res.setHeader('Content-Type', 'application/json');\r\n res.setHeader('Cache-Control', 'no-store');\r\n res.end(content);\r\n }\r\n return true;\r\n }\r\n\r\n //? Serve the HTML page itself. Title + branding + custom template\r\n //? layered: explicit `template` builder wins over the built-in renderer.\r\n //? Defaults render via `renderDocsHtml(jsonPath, title, { branding, enableTryItOut })`.\r\n const title = options.pageTitle ?? 'LuckyStack — API docs';\r\n const branding = options.branding ?? {};\r\n res.statusCode = 200;\r\n res.setHeader('Content-Type', 'text/html; charset=utf-8');\r\n res.setHeader('Cache-Control', 'no-store');\r\n const html = options.template\r\n ? options.template({ jsonPath, pageTitle: title, branding })\r\n : renderDocsHtml(jsonPath, title, {\r\n branding,\r\n enableTryItOut: options.enableTryItOut ?? false,\r\n });\r\n res.end(html);\r\n return true;\r\n };\r\n};\r\n","//? Embedded HTML for the docs page. Self-contained — no build-time bundler\r\n//? needed. Fetches `<routePath>/api.json` at runtime and renders the API\r\n//? catalog grouped by page. Style mirrors the framework's Tailwind tokens\r\n//? (background / container / muted / common) loosely so it doesn't look\r\n//? wildly out of place in projects that haven't customized them.\r\n\r\nexport interface RenderDocsHtmlOptions {\r\n branding?: {\r\n logoUrl?: string;\r\n brandColor?: string;\r\n fontFamily?: string;\r\n };\r\n /**\r\n * When true, the renderer injects an inline \"try-it-out\" form per\r\n * endpoint. The form calls `apiRequest` from `@luckystack/core` against\r\n * the live server. Disabled by default (requires a logged-in session).\r\n */\r\n enableTryItOut?: boolean;\r\n}\r\n\r\nexport const renderDocsHtml = (\r\n jsonPath: string,\r\n pageTitle: string,\r\n options: RenderDocsHtmlOptions = {},\r\n): string => {\r\n const branding = options.branding ?? {};\r\n const accent = branding.brandColor ?? '#58a6ff';\r\n const fontFamily = branding.fontFamily ?? `system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif`;\r\n const logoMarkup = branding.logoUrl\r\n ? `<img src=\"${escapeHtml(branding.logoUrl)}\" alt=\"logo\" style=\"height:32px;width:auto;margin-right:12px;\" />`\r\n : '';\r\n const tryItOutData = options.enableTryItOut ? 'true' : 'false';\r\n return `<!DOCTYPE html>\r\n<html lang=\"en\">\r\n<head>\r\n<meta charset=\"utf-8\" />\r\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\r\n<title>${escapeHtml(pageTitle)}</title>\r\n<style>\r\n :root {\r\n --bg: #0e1116;\r\n --container: #161b22;\r\n --container-hover: #1c232b;\r\n --border: #2a313c;\r\n --title: #e6edf3;\r\n --common: #c9d1d9;\r\n --muted: #8b949e;\r\n --accent: ${accent};\r\n --font-family: ${fontFamily};\r\n --get: #3fb950;\r\n --post: ${accent};\r\n --put: #d29922;\r\n --delete: #f85149;\r\n }\r\n @media (prefers-color-scheme: light) {\r\n :root {\r\n --bg: #ffffff;\r\n --container: #f6f8fa;\r\n --container-hover: #eaeef2;\r\n --border: #d0d7de;\r\n --title: #1f2328;\r\n --common: #1f2328;\r\n --muted: #59636e;\r\n }\r\n }\r\n * { box-sizing: border-box; }\r\n body {\r\n margin: 0; padding: 0;\r\n font-family: var(--font-family);\r\n background: var(--bg); color: var(--common);\r\n line-height: 1.5;\r\n }\r\n .brand-row { display: flex; align-items: center; }\r\n .try-it-out { margin-top: 12px; padding: 12px; background: var(--container-hover); border: 1px solid var(--border); border-radius: 6px; }\r\n .try-it-out textarea { width: 100%; min-height: 80px; background: var(--bg); color: var(--common); border: 1px solid var(--border); padding: 6px; font-family: ui-monospace, monospace; font-size: 12px; }\r\n .try-it-out button { margin-top: 6px; padding: 6px 12px; background: var(--accent); color: white; border: 0; border-radius: 4px; cursor: pointer; }\r\n .try-it-out pre.result { margin-top: 8px; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; max-height: 240px; overflow: auto; font-size: 12px; }\r\n .layout {\r\n max-width: 1200px;\r\n margin: 0 auto;\r\n padding: 32px 24px 96px;\r\n }\r\n h1 { color: var(--title); margin: 0 0 4px; font-size: 28px; }\r\n .lead { color: var(--muted); margin: 0 0 32px; font-size: 14px; }\r\n .summary { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }\r\n .summary-pill {\r\n background: var(--container);\r\n border: 1px solid var(--border);\r\n padding: 8px 14px;\r\n border-radius: 8px;\r\n font-size: 13px;\r\n }\r\n .summary-pill strong { color: var(--title); }\r\n .filter-bar {\r\n margin-bottom: 24px;\r\n display: flex;\r\n gap: 12px;\r\n align-items: center;\r\n }\r\n input[type=\"search\"] {\r\n flex: 1;\r\n background: var(--container);\r\n border: 1px solid var(--border);\r\n color: var(--common);\r\n padding: 10px 14px;\r\n border-radius: 8px;\r\n font-size: 14px;\r\n font-family: inherit;\r\n }\r\n input[type=\"search\"]:focus {\r\n outline: none;\r\n border-color: var(--accent);\r\n }\r\n .group {\r\n margin-bottom: 24px;\r\n background: var(--container);\r\n border: 1px solid var(--border);\r\n border-radius: 12px;\r\n overflow: hidden;\r\n }\r\n .group-header {\r\n padding: 12px 18px;\r\n background: var(--container-hover);\r\n font-size: 13px;\r\n font-weight: 600;\r\n color: var(--muted);\r\n text-transform: uppercase;\r\n letter-spacing: 0.04em;\r\n border-bottom: 1px solid var(--border);\r\n }\r\n .endpoint {\r\n padding: 14px 18px;\r\n border-bottom: 1px solid var(--border);\r\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Consolas, monospace;\r\n font-size: 13px;\r\n cursor: pointer;\r\n transition: background 80ms;\r\n }\r\n .endpoint:last-child { border-bottom: none; }\r\n .endpoint:hover { background: var(--container-hover); }\r\n .endpoint-summary { display: flex; align-items: center; gap: 12px; }\r\n .method {\r\n display: inline-block;\r\n min-width: 56px;\r\n padding: 2px 8px;\r\n border-radius: 4px;\r\n font-size: 11px;\r\n font-weight: 700;\r\n text-align: center;\r\n color: #fff;\r\n }\r\n .method.GET { background: var(--get); }\r\n .method.POST { background: var(--post); }\r\n .method.PUT { background: var(--put); color: #1f2328; }\r\n .method.DELETE { background: var(--delete); }\r\n .endpoint-name { color: var(--title); font-weight: 500; }\r\n .endpoint-meta { margin-left: auto; color: var(--muted); font-size: 12px; }\r\n .endpoint-detail {\r\n margin-top: 12px;\r\n padding: 12px;\r\n background: var(--bg);\r\n border-radius: 8px;\r\n border: 1px solid var(--border);\r\n display: none;\r\n white-space: pre-wrap;\r\n word-break: break-word;\r\n }\r\n .endpoint.open .endpoint-detail { display: block; }\r\n .detail-section { margin-bottom: 12px; }\r\n .detail-section:last-child { margin-bottom: 0; }\r\n .detail-label {\r\n color: var(--muted);\r\n font-size: 11px;\r\n text-transform: uppercase;\r\n letter-spacing: 0.04em;\r\n margin-bottom: 4px;\r\n }\r\n pre {\r\n margin: 0;\r\n color: var(--common);\r\n font-size: 12px;\r\n overflow-x: auto;\r\n }\r\n .auth-tag {\r\n display: inline-block;\r\n padding: 1px 6px;\r\n border-radius: 4px;\r\n background: var(--container-hover);\r\n color: var(--muted);\r\n font-size: 11px;\r\n margin-right: 4px;\r\n }\r\n .empty {\r\n padding: 48px;\r\n text-align: center;\r\n color: var(--muted);\r\n }\r\n a.json-link { color: var(--accent); }\r\n</style>\r\n</head>\r\n<body>\r\n<div class=\"layout\">\r\n <div class=\"brand-row\">${logoMarkup}<h1>${escapeHtml(pageTitle)}</h1></div>\r\n <p class=\"lead\">Generated from <code>apiDocs.generated.json</code>. Raw JSON: <a class=\"json-link\" href=\"${escapeHtml(jsonPath)}\">${escapeHtml(jsonPath)}</a></p>\r\n <div class=\"summary\" id=\"summary\"></div>\r\n <div class=\"filter-bar\">\r\n <input type=\"search\" id=\"filter\" placeholder=\"Filter by route name…\" autofocus />\r\n </div>\r\n <div id=\"content\"></div>\r\n</div>\r\n<script>\r\n const JSON_PATH = ${JSON.stringify(jsonPath)};\r\n const ENABLE_TRY_IT_OUT = ${tryItOutData};\r\n const stateByKey = new Map();\r\n\r\n //? Inline runner: hits the live server using the same fetch transport\r\n //? that the framework apiRequest helper uses. Auth comes from the\r\n //? browser's existing session cookie or sessionStorage; no token prompt.\r\n const runEndpoint = async (button, route, version, dataField) => {\r\n let parsed;\r\n try {\r\n parsed = dataField.value.trim().length === 0 ? {} : JSON.parse(dataField.value);\r\n } catch (err) {\r\n const resultEl = button.parentElement.querySelector('pre.result');\r\n resultEl.textContent = 'JSON parse error: ' + err.message;\r\n return;\r\n }\r\n button.disabled = true;\r\n const resultEl = button.parentElement.querySelector('pre.result');\r\n resultEl.textContent = 'Sending...';\r\n try {\r\n const response = await fetch('/' + route + '?stream=false', {\r\n method: 'POST',\r\n headers: { 'Content-Type': 'application/json' },\r\n credentials: 'include',\r\n body: JSON.stringify(parsed),\r\n });\r\n const text = await response.text();\r\n try {\r\n resultEl.textContent = JSON.stringify(JSON.parse(text), null, 2);\r\n } catch {\r\n resultEl.textContent = text;\r\n }\r\n } catch (err) {\r\n resultEl.textContent = 'Network error: ' + err.message;\r\n } finally {\r\n button.disabled = false;\r\n }\r\n };\r\n\r\n const renderTryItOut = (route, version) => {\r\n if (!ENABLE_TRY_IT_OUT) return '';\r\n return '<div class=\"try-it-out\">' +\r\n '<div style=\"font-weight:600;font-size:12px;margin-bottom:4px;\">Try it out (live request)</div>' +\r\n '<textarea placeholder=\\\\'{\"key\":\"value\"}\\\\'>{}</textarea>' +\r\n '<button onclick=\"runEndpoint(this,\\\\'' + route + '\\\\',\\\\'' + version + '\\\\',this.previousElementSibling)\">Send</button>' +\r\n '<pre class=\"result\"></pre>' +\r\n '</div>';\r\n };\r\n\r\n const escapeHtml = (str) => String(str).replace(/[&<>\"']/g, (c) => ({\r\n '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": '''\r\n })[c]);\r\n\r\n const renderAuth = (auth) => {\r\n if (!auth) return '<span class=\"auth-tag\">public</span>';\r\n const tags = [];\r\n if (auth.login) tags.push('login required');\r\n if (Array.isArray(auth.additional)) {\r\n for (const rule of auth.additional) {\r\n if (rule.key) tags.push(rule.key + (rule.value !== undefined ? '=' + JSON.stringify(rule.value) : ''));\r\n }\r\n }\r\n if (tags.length === 0) tags.push('public');\r\n return tags.map(t => '<span class=\"auth-tag\">' + escapeHtml(t) + '</span>').join('');\r\n };\r\n\r\n const renderEndpoint = (page, name, version, meta) => {\r\n const key = page + '/' + name + '@' + version;\r\n const method = (meta.method || 'POST').toUpperCase();\r\n const rate = meta.rateLimit;\r\n const rateLabel = rate === false ? 'no rate limit' : rate === undefined ? 'default rate' : (rate + '/min');\r\n const isOpen = stateByKey.get(key) === true;\r\n const path = '/api/' + page + '/' + name + '/' + version;\r\n return \\`\r\n <div class=\"endpoint \\${isOpen ? 'open' : ''}\" data-key=\"\\${escapeHtml(key)}\">\r\n <div class=\"endpoint-summary\">\r\n <span class=\"method \\${method}\">\\${method}</span>\r\n <span class=\"endpoint-name\">\\${escapeHtml(path)}</span>\r\n <span class=\"endpoint-meta\">\\${escapeHtml(rateLabel)}</span>\r\n </div>\r\n <div class=\"endpoint-detail\">\r\n <div class=\"detail-section\">\r\n <div class=\"detail-label\">auth</div>\r\n <div>\\${renderAuth(meta.auth)}</div>\r\n </div>\r\n <div class=\"detail-section\">\r\n <div class=\"detail-label\">input</div>\r\n <pre>\\${escapeHtml(meta.input || '{}')}</pre>\r\n </div>\r\n <div class=\"detail-section\">\r\n <div class=\"detail-label\">output</div>\r\n <pre>\\${escapeHtml(meta.output || 'unknown')}</pre>\r\n </div>\r\n \\${meta.stream ? \\`\r\n <div class=\"detail-section\">\r\n <div class=\"detail-label\">stream</div>\r\n <pre>\\${escapeHtml(meta.stream)}</pre>\r\n </div>\r\n \\` : ''}\r\n \\${meta.owner ? \\`\r\n <div class=\"detail-section\">\r\n <div class=\"detail-label\">owner</div>\r\n <div>\\${escapeHtml(meta.owner)}</div>\r\n </div>\r\n \\` : ''}\r\n \\${meta.tags && meta.tags.length ? \\`\r\n <div class=\"detail-section\">\r\n <div class=\"detail-label\">tags</div>\r\n <div>\\${meta.tags.map(t => '<span class=\"badge\">' + escapeHtml(String(t)) + '</span>').join(' ')}</div>\r\n </div>\r\n \\` : ''}\r\n \\${meta.deprecated ? \\`\r\n <div class=\"detail-section\">\r\n <div class=\"detail-label\">deprecated</div>\r\n <div style=\"color:var(--delete);\">\\${escapeHtml(String(meta.deprecated))}</div>\r\n </div>\r\n \\` : ''}\r\n \\${renderTryItOut(page + '/' + name + '/' + version, version)}\r\n </div>\r\n </div>\r\n \\`;\r\n };\r\n\r\n const passesFilter = (filter, page, name) => {\r\n if (!filter) return true;\r\n const haystack = (page + '/' + name).toLowerCase();\r\n return haystack.includes(filter.toLowerCase());\r\n };\r\n\r\n const render = (data, filter) => {\r\n const summary = document.getElementById('summary');\r\n const content = document.getElementById('content');\r\n const apis = data && data.apis ? data.apis : data;\r\n if (!apis || typeof apis !== 'object') {\r\n content.innerHTML = '<div class=\"empty\">No API docs available. Run <code>npm run generateArtifacts</code> to generate them.</div>';\r\n summary.innerHTML = '';\r\n return;\r\n }\r\n\r\n let total = 0;\r\n let visible = 0;\r\n const groups = [];\r\n for (const [page, names] of Object.entries(apis)) {\r\n const rows = [];\r\n for (const [name, versions] of Object.entries(names)) {\r\n for (const [version, meta] of Object.entries(versions)) {\r\n total++;\r\n if (!passesFilter(filter, page, name)) continue;\r\n visible++;\r\n rows.push(renderEndpoint(page, name, version, meta || {}));\r\n }\r\n }\r\n if (rows.length > 0) {\r\n groups.push(\\`\r\n <div class=\"group\">\r\n <div class=\"group-header\">\\${escapeHtml(page)}</div>\r\n \\${rows.join('')}\r\n </div>\r\n \\`);\r\n }\r\n }\r\n\r\n summary.innerHTML = \\`\r\n <div class=\"summary-pill\"><strong>\\${visible}</strong> of <strong>\\${total}</strong> endpoints</div>\r\n <div class=\"summary-pill\"><strong>\\${Object.keys(apis).length}</strong> pages</div>\r\n \\`;\r\n content.innerHTML = groups.join('') || '<div class=\"empty\">No matches.</div>';\r\n\r\n document.querySelectorAll('.endpoint').forEach((el) => {\r\n el.addEventListener('click', () => {\r\n const key = el.getAttribute('data-key');\r\n const next = !el.classList.contains('open');\r\n stateByKey.set(key, next);\r\n el.classList.toggle('open');\r\n });\r\n });\r\n };\r\n\r\n let cachedData = null;\r\n document.getElementById('filter').addEventListener('input', (e) => {\r\n if (cachedData) render(cachedData, e.target.value);\r\n });\r\n\r\n fetch(JSON_PATH, { credentials: 'include' })\r\n .then(r => r.ok ? r.json() : Promise.reject(new Error(String(r.status))))\r\n .then(data => { cachedData = data; render(data, ''); })\r\n .catch(err => {\r\n document.getElementById('content').innerHTML =\r\n '<div class=\"empty\">Could not load API docs: ' + escapeHtml(err.message) + '</div>';\r\n });\r\n</script>\r\n</body>\r\n</html>`;\r\n};\r\n\r\nconst HTML_ESCAPE_MAP: Record<string, string> = {\r\n '&': '&',\r\n '<': '<',\r\n '>': '>',\r\n '\"': '"',\r\n \"'\": ''',\r\n};\r\n\r\nconst escapeHtml = (str: string): string =>\r\n str.replaceAll(/[&<>\"']/g, (c) => HTML_ESCAPE_MAP[c] ?? c);\r\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/register.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/register.ts"],"sourcesContent":["//? Side-effect `./register` entry for @luckystack/docs-ui. Auto-imported at boot\n//? by @luckystack/server's `bootstrapLuckyStack` (BEFORE `createLuckyStackServer`\n//? reads the custom-route registry) when this package is installed, so the dev\n//? API docs page mounts at `/_docs` with NO consumer code edit — `npm i\n//? @luckystack/docs-ui` + restart is enough.\n//?\n//? `mountDocsUi()` auto-disables in production (returns 404) unless mounted with\n//? `{ enabledInProd: true }`. Consumers who want custom branding / routePath can\n//? still mount their own via a `luckystack/docs-ui/index.ts` overlay.\n//?\n//? This file statically imports `@luckystack/server` for `registerCustomRoute`,\n//? so @luckystack/docs-ui builds AFTER @luckystack/server in `buildPackages.mjs`.\n//? At runtime the importer IS @luckystack/server, so the dependency is always\n//? satisfied.\n\nimport { registerCustomRoute } from '@luckystack/server';\nimport { mountDocsUi } from './index';\n\nregisterCustomRoute(mountDocsUi());\n"],"mappings":";;;;;AAeA,SAAS,2BAA2B;AAGpC,oBAAoB,YAAY,CAAC;","names":[]}
|
package/docs/html-generation.md
CHANGED
|
@@ -129,23 +129,26 @@ Both map `&`, `<`, `>`, `"`, `'` to their HTML entity equivalents. The renderer
|
|
|
129
129
|
Enabled by `options.enableTryItOut === true`. When on:
|
|
130
130
|
|
|
131
131
|
- `ENABLE_TRY_IT_OUT` is `true` inside the inline script.
|
|
132
|
-
- `renderTryItOut(route,
|
|
133
|
-
- The Send click invokes `runEndpoint(button, route,
|
|
132
|
+
- `renderTryItOut(route, method)` appends a `<div class="try-it-out">` containing a `<textarea>` (default content `{}`), a `Send` button, and an empty `<pre class="result">`.
|
|
133
|
+
- The Send click invokes `runEndpoint(button, route, method, dataField)`.
|
|
134
134
|
|
|
135
135
|
`runEndpoint` performs:
|
|
136
136
|
|
|
137
137
|
```js
|
|
138
|
+
const httpMethod = (method || 'POST').toUpperCase();
|
|
139
|
+
const hasBody = httpMethod !== 'GET' && httpMethod !== 'DELETE';
|
|
138
140
|
fetch('/' + route + '?stream=false', {
|
|
139
|
-
method:
|
|
140
|
-
headers: { 'Content-Type': 'application/json' },
|
|
141
|
+
method: httpMethod,
|
|
142
|
+
headers: hasBody ? { 'Content-Type': 'application/json' } : {},
|
|
141
143
|
credentials: 'include',
|
|
142
|
-
body: JSON.stringify(parsed),
|
|
144
|
+
body: hasBody ? JSON.stringify(parsed) : undefined,
|
|
143
145
|
})
|
|
144
146
|
```
|
|
145
147
|
|
|
146
148
|
Behavior:
|
|
147
149
|
|
|
148
|
-
-
|
|
150
|
+
- The endpoint's declared HTTP method is threaded through (the framework returns `405` on a method mismatch, so a hardcoded `POST` would break every documented GET/PUT/DELETE route). For `GET`/`DELETE` the JSON body and `Content-Type` header are omitted.
|
|
151
|
+
- `dataField.value.trim()` empty ⇒ sends `{}` (for body-bearing methods). Otherwise the textarea value is parsed with `JSON.parse`. A parse error is shown in the result pane and no request is sent.
|
|
149
152
|
- The button is disabled during flight and re-enabled in `finally`.
|
|
150
153
|
- The response body is parsed as JSON when possible (pretty-printed via `JSON.stringify(..., null, 2)`) and otherwise displayed as raw text — this keeps non-JSON error pages readable.
|
|
151
154
|
- `?stream=false` instructs the HTTP API surface to return the full final payload instead of a streamed chunked response, which is what the textarea-and-pre UI is built for.
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckystack/docs-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
|
-
"access": "public"
|
|
6
|
+
"access": "public",
|
|
7
|
+
"provenance": true
|
|
7
8
|
},
|
|
8
9
|
"type": "module",
|
|
9
10
|
"description": "Dev-only API docs UI for LuckyStack. Mounts a Swagger-style page at /_docs that reads `apiDocs.generated.json` and renders every API route, its inputs/outputs, auth, and rate-limit metadata.",
|
|
@@ -34,6 +35,10 @@
|
|
|
34
35
|
".": {
|
|
35
36
|
"types": "./dist/index.d.ts",
|
|
36
37
|
"import": "./dist/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./register": {
|
|
40
|
+
"types": "./dist/register.d.ts",
|
|
41
|
+
"import": "./dist/register.js"
|
|
37
42
|
}
|
|
38
43
|
},
|
|
39
44
|
"files": [
|
|
@@ -50,6 +55,14 @@
|
|
|
50
55
|
"test": "vitest run"
|
|
51
56
|
},
|
|
52
57
|
"dependencies": {
|
|
53
|
-
"@luckystack/core": "^0.
|
|
58
|
+
"@luckystack/core": "^0.2.0"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@luckystack/server": "^0.2.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@luckystack/server": {
|
|
65
|
+
"optional": true
|
|
66
|
+
}
|
|
54
67
|
}
|
|
55
68
|
}
|