@luckystack/docs-ui 0.1.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/dist/index.js ADDED
@@ -0,0 +1,451 @@
1
+ // src/index.ts
2
+ import fs from "fs/promises";
3
+ import { getGeneratedApiDocsPath, tryCatch } from "@luckystack/core";
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
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
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
+ "&": "&amp;",
387
+ "<": "&lt;",
388
+ ">": "&gt;",
389
+ '"': "&quot;",
390
+ "'": "&#39;"
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
+ };
448
+ export {
449
+ mountDocsUi
450
+ };
451
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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 '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;'\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 '&': '&amp;',\r\n '<': '&lt;',\r\n '>': '&gt;',\r\n '\"': '&quot;',\r\n \"'\": '&#39;',\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":[]}
@@ -0,0 +1,150 @@
1
+ # Extension fields on `apiDocs.generated.json`
2
+
3
+ > Dev-only. These fields are read by the renderer in `@luckystack/docs-ui` only — the framework runtime never inspects them. They are a docs-presentation concern, not a routing concern.
4
+
5
+ `apiDocs.generated.json` is produced by `@luckystack/devkit`'s type-map emitter. The default template in `@luckystack/docs-ui` consumes a small, stable set of core fields and a handful of optional extension fields. Unknown fields pass through cleanly — the renderer ignores anything it doesn't recognize, so adding new metadata is forward-compatible.
6
+
7
+ This document covers:
8
+
9
+ - The top-level JSON shape (including the legacy fallback).
10
+ - Core fields the renderer reads from each endpoint's `meta` object.
11
+ - Optional extension fields rendered when present (`stream`, `owner`, `tags`, `deprecated`).
12
+ - How to add your own metadata via the devkit emitter hooks.
13
+
14
+ For the rendering pipeline that consumes these fields see `./html-generation.md`. For the route that serves the JSON see `./mounting.md`.
15
+
16
+ ## Top-level JSON shape
17
+
18
+ The current artifact is:
19
+
20
+ ```jsonc
21
+ {
22
+ "apis": {
23
+ "<page>": {
24
+ "<name>": {
25
+ "<version>": {
26
+ "method": "POST",
27
+ "auth": { "login": true, "additional": [] },
28
+ "rateLimit": 60,
29
+ "input": "{ userId: string }",
30
+ "output": "{ status: 'success', result: { ... } }",
31
+ // ...optional extension fields...
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ Legacy/forward fallback: if the root object is the apis map itself (no `apis` wrapper), the renderer normalizes via `data && data.apis ? data.apis : data`. Both shapes are accepted; new code should always emit the wrapped form.
40
+
41
+ The grouping levels — `page` → `name` → `version` — match the file-based routing convention (`src/<page>/_api/<name>_v<version>.ts`). The renderer iterates these in object-iteration order; the emitter is responsible for any sorting.
42
+
43
+ ## Core fields (always read by the renderer)
44
+
45
+ These are the fields used to build the always-visible row + the default expanded panel:
46
+
47
+ | Field | Type | Default in renderer | Purpose |
48
+ | --- | --- | --- | --- |
49
+ | `meta.method` | `'GET' \| 'POST' \| 'PUT' \| 'DELETE'` (any string, uppercased) | `'POST'` | Method pill text + CSS class on `.method`. |
50
+ | `meta.rateLimit` | `number \| false \| undefined` | `'default rate'` label | Numeric ⇒ `"<n>/min"`. `false` ⇒ `"no rate limit"`. `undefined` ⇒ `"default rate"`. |
51
+ | `meta.auth` | `{ login?: boolean; additional?: Array<{ key: string; value?: unknown }> }` | `'public'` badge | `auth.login` becomes a `"login required"` badge. Each `additional[]` entry becomes a `"<key>"` or `"<key>=<JSON-stringified value>"` badge. Empty / missing ⇒ single `"public"` badge. |
52
+ | `meta.input` | `string` (already-stringified TS shape) | `'{}'` | Rendered inside `<pre>` in the expanded panel. |
53
+ | `meta.output` | `string` (already-stringified TS shape) | `'unknown'` | Rendered inside `<pre>` in the expanded panel. |
54
+
55
+ The renderer does not try to JSON-parse `input` / `output`. They are emitted as pre-formatted strings by the devkit emitter so the docs page can display the original TypeScript shape verbatim — including unions, mapped types, and template-literal types — without re-stringifying.
56
+
57
+ Unknown core fields fall through to their defaults silently; emit a missing `method`, for example, and the row shows `POST`.
58
+
59
+ ## Optional extension fields
60
+
61
+ The default template renders these only when present. They are additive: omit any of them and the corresponding section is left out of the expanded panel.
62
+
63
+ ### `meta.stream`
64
+
65
+ - Type: `string` (already-stringified TS shape, same convention as `input` / `output`).
66
+ - Renders as: a `stream` detail section with the value inside a `<pre>` block.
67
+ - Use for endpoints that stream chunks back to the client (e.g. SSE or chunked-transfer responses). The string typically describes the shape of a single chunk.
68
+ - Example payload field:
69
+
70
+ ```jsonc
71
+ {
72
+ "stream": "{ delta: string } | { done: true }"
73
+ }
74
+ ```
75
+
76
+ ### `meta.owner`
77
+
78
+ - Type: `string`.
79
+ - Renders as: an `owner` detail section with the raw string.
80
+ - Use for triage-friendly attribution — team name, Slack channel, GitHub handle, on-call rotation, etc. The value is escaped before injection so it is safe to include arbitrary text.
81
+ - Example:
82
+
83
+ ```jsonc
84
+ { "owner": "@platform-team (#help-platform)" }
85
+ ```
86
+
87
+ ### `meta.tags`
88
+
89
+ - Type: `string[]` (or array of values coerced via `String(...)`).
90
+ - Renders as: inline badges in a `tags` detail section, space-separated.
91
+ - Use for cross-cutting categorization the page/name hierarchy can't express — `"v2-migration"`, `"internal"`, `"experimental"`, etc.
92
+ - Example:
93
+
94
+ ```jsonc
95
+ { "tags": ["experimental", "internal"] }
96
+ ```
97
+
98
+ Note: the renderer treats `tags` as an `Array.isArray(value)` check; arbitrary iterables are not supported. Emit a plain array.
99
+
100
+ ### `meta.deprecated`
101
+
102
+ - Type: `string | true` (anything truthy; `String(value)` is used for display).
103
+ - Renders as: a `deprecated` detail section with red text (`color: var(--delete)`).
104
+ - Use for a removal notice or migration pointer. The truthiness check means `"true"`, `"yes"`, `"removing v2026.05"`, etc. all render — but the renderer always shows the string value, so prefer a human-readable migration note over a bare boolean.
105
+ - Example:
106
+
107
+ ```jsonc
108
+ {
109
+ "deprecated": "Removed in v2026.05 — use users/getProfile/v2 instead."
110
+ }
111
+ ```
112
+
113
+ ## Forward compatibility
114
+
115
+ - Adding a new field to a `meta` object is non-breaking. The renderer ignores it.
116
+ - Removing one of the four extension fields above does **not** break the renderer either — each is conditional on truthiness or array length.
117
+ - The renderer does no schema validation. Malformed top-level JSON (e.g. wrong types at the page/name/version levels) shows the page's empty state with a hint to regenerate.
118
+
119
+ If you need extra fields on the page, you have two options:
120
+
121
+ 1. Extend the devkit emitter to include the field, then customize the rendering side via a `DocsTemplateBuilder` (see `./theming.md`) or, for trivial additions, contribute the field to this package.
122
+ 2. Keep your metadata out-of-band (a sibling JSON file) and consume it from a custom template. The default renderer will not surface it, but a `DocsTemplateBuilder` can fetch both files.
123
+
124
+ ## Producer contract — how fields are populated
125
+
126
+ `apiDocs.generated.json` is written by `@luckystack/devkit`'s type-map emitter on each dev-server reload and on every `npm run generateArtifacts`. Path resolution goes through `ProjectConfig.paths.generatedApiDocs` (consumed by `getGeneratedApiDocsPath()` in `@luckystack/core`).
127
+
128
+ Emission steps the devkit performs per endpoint:
129
+
130
+ 1. Read the route file (`src/<page>/_api/<name>_v<version>.ts`) and extract exports: `method`, `rateLimit`, `auth`, the `ApiParams.data` interface (rendered into `input`), and the `main` return type (rendered into `output`).
131
+ 2. Stringify the types into source-faithful strings — no re-stringification, no JSON.stringify of types.
132
+ 3. Run the emitter's `emitterArtifacts` extension hooks (`packages/devkit/src/typeMap/emitterArtifacts.ts`). Hooks can attach `stream`, `owner`, `tags`, `deprecated`, or any new field.
133
+ 4. Group by `page` → `name` → `version` and write the JSON.
134
+
135
+ Consumer-side extension (e.g. reading a `@deprecated` JSDoc tag and mapping it to `meta.deprecated`) belongs in an emitter artifact hook, not in `@luckystack/docs-ui`. The docs-ui package only renders what the emitter writes.
136
+
137
+ ## Validation behavior
138
+
139
+ - No schema validation. The renderer trusts the JSON shape.
140
+ - A missing artifact (`apiDocs.generated.json` not on disk) yields a `404 JSON` response from the `mountDocsUi` handler with `expectedAt` + `hint` — surfaced as an inline error message in the page.
141
+ - An empty / invalid `apis` map yields the page's empty state: `"No API docs available. Run npm run generateArtifacts to generate them."`.
142
+ - Per-endpoint malformations (e.g. `auth` is a string instead of an object) fall back to defaults silently. Bad data does not crash the page; it just shows degraded content.
143
+
144
+ ## Related
145
+
146
+ - Rendering pipeline that consumes these fields: `./html-generation.md`
147
+ - Mount + route: `./mounting.md`
148
+ - Branding / custom templates: `./theming.md`
149
+ - AI summary + function index: `../CLAUDE.md`
150
+ - Producer: `packages/devkit/src/typeMap/emitter.ts` and `packages/devkit/src/typeMap/emitterArtifacts.ts`