@luckystack/docs-ui 0.2.7 → 0.3.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/README.md +4 -0
- package/dist/{chunk-WPMWNQTQ.js → chunk-XFLDJWH2.js} +45 -8
- package/dist/chunk-XFLDJWH2.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/register.js +1 -1
- package/docs/extension-fields.md +40 -21
- package/docs/html-generation.md +7 -6
- package/docs/mounting.md +13 -8
- package/package.json +3 -3
- package/dist/chunk-WPMWNQTQ.js.map +0 -1
package/README.md
CHANGED
|
@@ -63,6 +63,10 @@ your APIs are intentionally documented to the world.
|
|
|
63
63
|
| `pageTitle` | `LuckyStack — API docs` | Shown in the header + browser tab. Pass your project name explicitly — there is no implicit lookup from `ProjectConfig`. |
|
|
64
64
|
| `enabledInProd` | `false` | Set to `true` to render the docs in production. |
|
|
65
65
|
| `apiDocsPath` | `getGeneratedApiDocsPath()` | Override the JSON file path. Default reads from `ProjectConfig.paths.generatedApiDocs`. |
|
|
66
|
+
| `branding` | `{}` | `{ logoUrl?, brandColor?, fontFamily? }` applied by the default template (logo in the header, accent color, font). Ignored when a custom `template` is supplied. |
|
|
67
|
+
| `template` | `undefined` | `DocsTemplateBuilder` — a custom HTML builder `({ jsonPath, pageTitle, branding }) => string`. When set, the default `renderDocsHtml` is bypassed entirely. |
|
|
68
|
+
| `enableTryItOut` | `false` | Renders an inline request runner (textarea + Send) under each endpoint that calls the live server with `credentials: 'include'`. Needs a logged-in browser session. |
|
|
69
|
+
| `authorize` | `undefined` | `(req) => boolean \| Promise<boolean>` per-request hook, run after the env/bind-address gate. Return `false` to serve `403`. Use to restrict docs access on non-loopback deployments. |
|
|
66
70
|
|
|
67
71
|
## How it works
|
|
68
72
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import fs from "fs/promises";
|
|
3
|
-
import { getBindAddress, getGeneratedApiDocsPath, isLoopbackIp, tryCatch } from "@luckystack/core";
|
|
3
|
+
import { getBindAddress, getGeneratedApiDocsPath, isLoopbackIp, resolveEnvKey, tryCatch } from "@luckystack/core";
|
|
4
4
|
|
|
5
5
|
// src/docsHtml.ts
|
|
6
6
|
import { escapeHtml } from "@luckystack/core";
|
|
@@ -10,7 +10,7 @@ var isSafeLogoUrl = (url) => {
|
|
|
10
10
|
const parsed = new URL(url);
|
|
11
11
|
return parsed.protocol === "https:" || parsed.protocol === "http:";
|
|
12
12
|
} catch {
|
|
13
|
-
return !url.includes(":");
|
|
13
|
+
return !url.includes(":") && !url.startsWith("//");
|
|
14
14
|
}
|
|
15
15
|
};
|
|
16
16
|
var renderDocsCss = (accent, fontFamily) => `<style>
|
|
@@ -184,11 +184,31 @@ var renderDocsCss = (accent, fontFamily) => `<style>
|
|
|
184
184
|
}
|
|
185
185
|
a.json-link { color: var(--accent); }
|
|
186
186
|
</style>`;
|
|
187
|
+
var LT_UNICODE_ESCAPE = String.fromCodePoint(92) + "u003c";
|
|
187
188
|
var renderDocsScript = (jsonPath, tryItOutData) => `<script>
|
|
188
|
-
const JSON_PATH = ${JSON.stringify(jsonPath)};
|
|
189
|
+
const JSON_PATH = ${JSON.stringify(jsonPath).replaceAll("<", LT_UNICODE_ESCAPE)};
|
|
189
190
|
const ENABLE_TRY_IT_OUT = ${tryItOutData};
|
|
190
191
|
const stateByKey = new Map();
|
|
191
192
|
|
|
193
|
+
//? Page-session CSRF token cache. In the default cookie-mode every
|
|
194
|
+
//? state-changing request (POST/PUT/DELETE) must echo the token from
|
|
195
|
+
//? GET /auth/csrf as the x-csrf-token header, or the server 403s it. We
|
|
196
|
+
//? fetch once and reuse; a failed fetch leaves the cache null so a later
|
|
197
|
+
//? attempt can retry rather than poisoning the session.
|
|
198
|
+
let cachedCsrfToken = null;
|
|
199
|
+
const getCsrfToken = async () => {
|
|
200
|
+
if (cachedCsrfToken !== null) return cachedCsrfToken;
|
|
201
|
+
try {
|
|
202
|
+
const r = await fetch('/auth/csrf', { credentials: 'include' });
|
|
203
|
+
if (!r.ok) return null;
|
|
204
|
+
const body = await r.json();
|
|
205
|
+
cachedCsrfToken = (body && body.csrfToken) ? body.csrfToken : null;
|
|
206
|
+
} catch {
|
|
207
|
+
cachedCsrfToken = null;
|
|
208
|
+
}
|
|
209
|
+
return cachedCsrfToken;
|
|
210
|
+
};
|
|
211
|
+
|
|
192
212
|
//? Inline runner: hits the live server using the same fetch transport
|
|
193
213
|
//? that the framework apiRequest helper uses. Auth comes from the
|
|
194
214
|
//? browser's existing session cookie or sessionStorage; no token prompt.
|
|
@@ -197,6 +217,7 @@ var renderDocsScript = (jsonPath, tryItOutData) => `<script>
|
|
|
197
217
|
//? mismatch), so the runner must send that method, not a hardcoded POST.
|
|
198
218
|
const httpMethod = (method || 'POST').toUpperCase();
|
|
199
219
|
const hasBody = httpMethod !== 'GET' && httpMethod !== 'DELETE';
|
|
220
|
+
const isStateChanging = httpMethod !== 'GET';
|
|
200
221
|
let parsed;
|
|
201
222
|
try {
|
|
202
223
|
parsed = dataField.value.trim().length === 0 ? {} : JSON.parse(dataField.value);
|
|
@@ -209,9 +230,25 @@ var renderDocsScript = (jsonPath, tryItOutData) => `<script>
|
|
|
209
230
|
const resultEl = button.parentElement.querySelector('pre.result');
|
|
210
231
|
resultEl.textContent = 'Sending...';
|
|
211
232
|
try {
|
|
212
|
-
|
|
233
|
+
//? GET/DELETE carry no body \u2014 serialize the parsed input into the query
|
|
234
|
+
//? string instead so the user's input is not silently dropped. Non-string
|
|
235
|
+
//? values are JSON-encoded so objects/numbers survive the round-trip.
|
|
236
|
+
const params = new URLSearchParams();
|
|
237
|
+
params.set('stream', 'false');
|
|
238
|
+
if (!hasBody) {
|
|
239
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
240
|
+
params.set(k, typeof v === 'string' ? v : JSON.stringify(v));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const headers = hasBody ? { 'Content-Type': 'application/json' } : {};
|
|
244
|
+
//? Attach the CSRF token to state-changing requests (cookie-mode default).
|
|
245
|
+
if (isStateChanging) {
|
|
246
|
+
const csrf = await getCsrfToken();
|
|
247
|
+
if (csrf) headers['x-csrf-token'] = csrf;
|
|
248
|
+
}
|
|
249
|
+
const response = await fetch('/' + route + '?' + params.toString(), {
|
|
213
250
|
method: httpMethod,
|
|
214
|
-
headers
|
|
251
|
+
headers,
|
|
215
252
|
credentials: 'include',
|
|
216
253
|
body: hasBody ? JSON.stringify(parsed) : undefined,
|
|
217
254
|
});
|
|
@@ -506,7 +543,7 @@ var mountDocsUi = (options = {}) => {
|
|
|
506
543
|
return false;
|
|
507
544
|
}
|
|
508
545
|
const isPublicBind = !isLoopbackIp(getBindAddress().ip);
|
|
509
|
-
if ((
|
|
546
|
+
if ((resolveEnvKey() === "production" || isPublicBind) && !options.enabledInProd) {
|
|
510
547
|
res.statusCode = 404;
|
|
511
548
|
res.setHeader("Content-Type", "text/plain");
|
|
512
549
|
res.end("Not Found");
|
|
@@ -534,7 +571,7 @@ var mountDocsUi = (options = {}) => {
|
|
|
534
571
|
if (readError) {
|
|
535
572
|
res.statusCode = 404;
|
|
536
573
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
537
|
-
const isDev =
|
|
574
|
+
const isDev = resolveEnvKey() !== "production";
|
|
538
575
|
res.end(JSON.stringify({
|
|
539
576
|
error: "apiDocs.generated.json not found",
|
|
540
577
|
...isDev ? { expectedAt: docsPath } : {},
|
|
@@ -578,4 +615,4 @@ var mountDocsUi = (options = {}) => {
|
|
|
578
615
|
export {
|
|
579
616
|
mountDocsUi
|
|
580
617
|
};
|
|
581
|
-
//# sourceMappingURL=chunk-
|
|
618
|
+
//# sourceMappingURL=chunk-XFLDJWH2.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/docsHtml.ts"],"sourcesContent":["//? Dev-only API docs UI for LuckyStack.\n//?\n//? Mounts a single page at `/_docs` (configurable) that fetches the\n//? framework's `apiDocs.generated.json` and renders every API endpoint\n//? grouped by page, with method, auth, rate limit, input shape, and\n//? output shape.\n//?\n//? Usage:\n//? // luckystack/docs-ui/index.ts\n//? import { mountDocsUi } from '@luckystack/docs-ui';\n//? mountDocsUi(); // Auto-disables in production unless { enabledInProd: true }\n//?\n//? The mount function returns a `customRoutes` handler — pass it to\n//? `createLuckyStackServer({ customRoutes })` (or compose with another\n//? handler if you have one). It honors the request only when the URL\n//? matches the configured `routePath`; everything else falls through.\n\nimport fs from 'node:fs/promises';\nimport type { IncomingMessage, ServerResponse } from 'node:http';\nimport { getBindAddress, getGeneratedApiDocsPath, isLoopbackIp, resolveEnvKey, tryCatch } from '@luckystack/core';\nimport { renderDocsHtml } from './docsHtml';\n\nexport interface DocsBranding {\n /**\n * Logo URL shown in the header. Accepts `https:`, `http:`, and scheme-free\n * relative URLs. `data:` and `javascript:` URLs are rejected (the logo will\n * be silently omitted) because SVG data-URIs can carry script.\n */\n logoUrl?: string;\n /** CSS color for the header / accent (`#hex` or `rgb()` literal). */\n brandColor?: string;\n /** Web-safe font family for the page. */\n fontFamily?: string;\n}\n\n/**\n * Custom HTML builder. Receives the JSON-endpoint path the page should\n * fetch from + page title + branding; returns the full HTML document\n * (`<!doctype html>` + everything). Use to swap layout entirely (sidebar\n * vs tabs, dark/light theme, embedded auth-token field).\n */\nexport type DocsTemplateBuilder = (input: {\n jsonPath: string;\n pageTitle: string;\n branding: DocsBranding;\n}) => string;\n\nexport interface MountDocsUiOptions {\n /** Path the docs UI is served from. Default: `/_docs`. */\n routePath?: string;\n /**\n * Page title shown in the header + browser tab. Defaults to\n * \"LuckyStack — API docs\". Pass an explicit string to brand for a consumer.\n */\n pageTitle?: string;\n /**\n * Allow rendering the docs UI in production. Default: false. Useful only\n * when the docs are intentionally public (developer-portal, internal-only\n * deployment, etc.). When false, the route returns 404 in production.\n */\n enabledInProd?: boolean;\n /**\n * Override the path to `apiDocs.generated.json`. Defaults to whatever\n * `getGeneratedApiDocsPath()` resolves to via ProjectConfig.paths.\n */\n apiDocsPath?: string;\n /**\n * Optional branding inputs (logo, brand color, font). Applied by the\n * default template. Custom templates may ignore these.\n */\n branding?: DocsBranding;\n /**\n * Custom HTML template builder. When provided, the default `renderDocsHtml`\n * is bypassed entirely. Use for radically different layouts (sidebar,\n * tabs, dark mode, marketing-page-style). Receives a tested JSON path +\n * title + branding so your template doesn't need to know about\n * `getGeneratedApiDocsPath`.\n */\n template?: DocsTemplateBuilder;\n /**\n * Enable the inline \"try-it-out\" runner. When true, the default template\n * renders a request-input box + send button per endpoint that calls\n * `apiRequest` against the live server. Off by default because the\n * runner needs a logged-in session.\n */\n enableTryItOut?: boolean;\n /**\n * Optional per-request authorization hook. Called after the env/bind-address\n * gate passes. Return `true` to allow the request, `false` to serve 403.\n * Use to restrict docs access to authenticated or IP-allowlisted callers on\n * non-loopback deployments.\n */\n authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;\n}\n\nexport type DocsRouteHandler = (\n req: IncomingMessage,\n res: ServerResponse,\n) => Promise<boolean>;\n\n/**\n * Returns a route handler compatible with `createLuckyStackServer`'s\n * `customRoutes` option. The handler returns `true` (response sent) when\n * the URL matches the configured docs route, `false` otherwise.\n */\nexport const mountDocsUi = (options: MountDocsUiOptions = {}): DocsRouteHandler => {\n const routePath = options.routePath ?? '/_docs';\n const jsonPath = `${routePath}/api.json`;\n\n return async (req, res) => {\n const url = req.url ?? '';\n const [pathOnly] = url.split('?');\n\n if (pathOnly !== routePath && pathOnly !== jsonPath) {\n return false;\n }\n\n //? Fail-closed on production env OR any non-loopback bind address unless the\n //? consumer explicitly opts in. Staging/preview servers that bind to a public\n //? interface must set `enabledInProd` to avoid exposing the docs route.\n const isPublicBind = !isLoopbackIp(getBindAddress().ip);\n if ((resolveEnvKey() === 'production' || isPublicBind) && !options.enabledInProd) {\n res.statusCode = 404;\n res.setHeader('Content-Type', 'text/plain');\n res.end('Not Found');\n return true;\n }\n\n if (options.authorize) {\n const allowed = await options.authorize(req);\n if (!allowed) {\n res.statusCode = 403;\n res.setHeader('Content-Type', 'text/plain');\n res.end('Forbidden');\n return true;\n }\n }\n\n if (req.method !== 'GET') {\n res.statusCode = 405;\n res.setHeader('Content-Type', 'text/plain');\n res.setHeader('Allow', 'GET');\n res.end('Method Not Allowed');\n return true;\n }\n\n if (pathOnly === jsonPath) {\n const docsPath = options.apiDocsPath ?? getGeneratedApiDocsPath();\n const [readError, content] = await tryCatch(() => fs.readFile(docsPath, 'utf8'));\n if (readError) {\n res.statusCode = 404;\n res.setHeader('Content-Type', 'application/json; charset=utf-8');\n //? Only expose the absolute filesystem path in non-production envs to\n //? avoid leaking internal directory structure to callers (DOCSUI-8).\n //? Use the framework-canonical env resolver (honors LUCKYSTACK_ENV),\n //? mirroring the production gate above — not raw process.env.NODE_ENV.\n const isDev = resolveEnvKey() !== 'production';\n res.end(JSON.stringify({\n error: 'apiDocs.generated.json not found',\n ...(isDev ? { expectedAt: docsPath } : {}),\n hint: 'Run `npm run generateArtifacts` to generate it.',\n }));\n } else {\n //? Validate JSON before serving so a torn/corrupt artifact surfaces a\n //? meaningful 422 rather than a 200 with garbled payload that renders\n //? as \"Could not load API docs\" in the browser with no actionable hint\n //? (DD-DOCSUI-17). We parse but serve the original bytes — no\n //? round-trip serialization that could change whitespace or key order.\n try {\n JSON.parse(content ?? '');\n } catch {\n res.statusCode = 422;\n res.setHeader('Content-Type', 'application/json; charset=utf-8');\n res.end(JSON.stringify({\n error: 'apiDocs.generated.json is not valid JSON (file may be torn/corrupted)',\n hint: 'Run `npm run generateArtifacts` to regenerate it.',\n }));\n return true;\n }\n res.statusCode = 200;\n res.setHeader('Content-Type', 'application/json; charset=utf-8');\n res.setHeader('Cache-Control', 'no-store');\n res.end(content);\n }\n return true;\n }\n\n //? Serve the HTML page itself. Title + branding + custom template\n //? layered: explicit `template` builder wins over the built-in renderer.\n //? Defaults render via `renderDocsHtml(jsonPath, title, { branding, enableTryItOut })`.\n const title = options.pageTitle ?? 'LuckyStack — API docs';\n const branding = options.branding ?? {};\n res.statusCode = 200;\n res.setHeader('Content-Type', 'text/html; charset=utf-8');\n res.setHeader('Cache-Control', 'no-store');\n res.setHeader('X-Content-Type-Options', 'nosniff');\n res.setHeader('X-Frame-Options', 'DENY');\n const html = options.template\n ? options.template({ jsonPath, pageTitle: title, branding })\n : renderDocsHtml(jsonPath, title, {\n branding,\n enableTryItOut: options.enableTryItOut ?? false,\n });\n res.end(html);\n return true;\n };\n};\n","//? Embedded HTML for the docs page. Self-contained — no build-time bundler\n//? needed. Fetches `<routePath>/api.json` at runtime and renders the API\n//? catalog grouped by page. Style mirrors the framework's Tailwind tokens\n//? (background / container / muted / common) loosely so it doesn't look\n//? wildly out of place in projects that haven't customized them.\n\nimport { escapeHtml } from '@luckystack/core';\n\nexport interface RenderDocsHtmlOptions {\n branding?: {\n logoUrl?: string;\n brandColor?: string;\n fontFamily?: string;\n };\n /**\n * When true, the renderer injects an inline \"try-it-out\" form per\n * endpoint. The form calls `apiRequest` from `@luckystack/core` against\n * the live server. Disabled by default (requires a logged-in session).\n */\n enableTryItOut?: boolean;\n}\n\n//? Strip CSS-break characters from a value before injecting it into a\n//? <style> block. Removes `}` (would close the rule-set), `;` (would inject\n//? extra declarations), `<` (would close the <style> element), newlines\n//? (CR/LF — would break out of the declaration into adjacent lines),\n//? `url(` (would allow loading arbitrary external resources), and CSS comment\n//? delimiters `/*` / `*/` (would allow commenting out surrounding rules).\nconst sanitizeCssValue = (value: string): string =>\n value\n .replaceAll('}', '')\n .replaceAll(';', '')\n .replaceAll('<', '')\n .replaceAll('\\n', '')\n .replaceAll('\\r', '')\n .replaceAll('url(', '')\n .replaceAll('/*', '')\n .replaceAll('*/', '');\n\n//? Only allow http: and https: schemes for the logo URL to block javascript:\n//? and data: XSS vectors. data: URLs are explicitly rejected because an SVG\n//? data-URI can carry script.\nconst isSafeLogoUrl = (url: string): boolean => {\n try {\n const parsed = new URL(url);\n return parsed.protocol === 'https:' || parsed.protocol === 'http:';\n } catch {\n // Relative URLs (no scheme) are safe — they resolve on the same origin.\n // Reject protocol-relative URLs (`//host/...`): they parse-fail here yet the\n // browser loads them from an arbitrary external host over the page scheme.\n return !url.includes(':') && !url.startsWith('//');\n }\n};\n\n//? Renders the `<style>` block. Split out of `renderDocsHtml` so the\n//? document assembler stays readable; `accent` + `fontFamily` are the only\n//? two values interpolated into the stylesheet. Output is byte-identical to\n//? the previously inlined CSS.\nconst renderDocsCss = (accent: string, fontFamily: string): string => `<style>\n :root {\n --bg: #0e1116;\n --container: #161b22;\n --container-hover: #1c232b;\n --border: #2a313c;\n --title: #e6edf3;\n --common: #c9d1d9;\n --muted: #8b949e;\n --accent: ${accent};\n --font-family: ${fontFamily};\n --get: #3fb950;\n --post: ${accent};\n --put: #d29922;\n --delete: #f85149;\n }\n @media (prefers-color-scheme: light) {\n :root {\n --bg: #ffffff;\n --container: #f6f8fa;\n --container-hover: #eaeef2;\n --border: #d0d7de;\n --title: #1f2328;\n --common: #1f2328;\n --muted: #59636e;\n }\n }\n * { box-sizing: border-box; }\n body {\n margin: 0; padding: 0;\n font-family: var(--font-family);\n background: var(--bg); color: var(--common);\n line-height: 1.5;\n }\n .brand-row { display: flex; align-items: center; }\n .try-it-out { margin-top: 12px; padding: 12px; background: var(--container-hover); border: 1px solid var(--border); border-radius: 6px; }\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; }\n .try-it-out button { margin-top: 6px; padding: 6px 12px; background: var(--accent); color: white; border: 0; border-radius: 4px; cursor: pointer; }\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; }\n .layout {\n max-width: 1200px;\n margin: 0 auto;\n padding: 32px 24px 96px;\n }\n h1 { color: var(--title); margin: 0 0 4px; font-size: 28px; }\n .lead { color: var(--muted); margin: 0 0 32px; font-size: 14px; }\n .summary { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }\n .summary-pill {\n background: var(--container);\n border: 1px solid var(--border);\n padding: 8px 14px;\n border-radius: 8px;\n font-size: 13px;\n }\n .summary-pill strong { color: var(--title); }\n .filter-bar {\n margin-bottom: 24px;\n display: flex;\n gap: 12px;\n align-items: center;\n }\n input[type=\"search\"] {\n flex: 1;\n background: var(--container);\n border: 1px solid var(--border);\n color: var(--common);\n padding: 10px 14px;\n border-radius: 8px;\n font-size: 14px;\n font-family: inherit;\n }\n input[type=\"search\"]:focus {\n outline: none;\n border-color: var(--accent);\n }\n .group {\n margin-bottom: 24px;\n background: var(--container);\n border: 1px solid var(--border);\n border-radius: 12px;\n overflow: hidden;\n }\n .group-header {\n padding: 12px 18px;\n background: var(--container-hover);\n font-size: 13px;\n font-weight: 600;\n color: var(--muted);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n border-bottom: 1px solid var(--border);\n }\n .endpoint {\n padding: 14px 18px;\n border-bottom: 1px solid var(--border);\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Consolas, monospace;\n font-size: 13px;\n cursor: pointer;\n transition: background 80ms;\n }\n .endpoint:last-child { border-bottom: none; }\n .endpoint:hover { background: var(--container-hover); }\n .endpoint-summary { display: flex; align-items: center; gap: 12px; }\n .method {\n display: inline-block;\n min-width: 56px;\n padding: 2px 8px;\n border-radius: 4px;\n font-size: 11px;\n font-weight: 700;\n text-align: center;\n color: #fff;\n }\n .method.GET { background: var(--get); }\n .method.POST { background: var(--post); }\n .method.PUT { background: var(--put); color: #1f2328; }\n .method.DELETE { background: var(--delete); }\n .method.SYNC { background: var(--muted); }\n .badge {\n display: inline-block;\n padding: 1px 6px;\n border-radius: 4px;\n background: var(--container-hover);\n color: var(--muted);\n font-size: 11px;\n margin-right: 4px;\n }\n .endpoint-name { color: var(--title); font-weight: 500; }\n .endpoint-meta { margin-left: auto; color: var(--muted); font-size: 12px; }\n .endpoint-detail {\n margin-top: 12px;\n padding: 12px;\n background: var(--bg);\n border-radius: 8px;\n border: 1px solid var(--border);\n display: none;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .endpoint.open .endpoint-detail { display: block; }\n .detail-section { margin-bottom: 12px; }\n .detail-section:last-child { margin-bottom: 0; }\n .detail-label {\n color: var(--muted);\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n margin-bottom: 4px;\n }\n pre {\n margin: 0;\n color: var(--common);\n font-size: 12px;\n overflow-x: auto;\n }\n .auth-tag {\n display: inline-block;\n padding: 1px 6px;\n border-radius: 4px;\n background: var(--container-hover);\n color: var(--muted);\n font-size: 11px;\n margin-right: 4px;\n }\n .empty {\n padding: 48px;\n text-align: center;\n color: var(--muted);\n }\n a.json-link { color: var(--accent); }\n</style>`;\n\n//? Renders the self-contained client `<script>` block. Split out of\n//? `renderDocsHtml` for readability; `jsonPath` (fetch target, injected as a\n//? JSON.stringify literal) and `tryItOutData` (`'true'`/`'false'`) are the\n//? only two interpolations. This is browser-runtime JavaScript embedded as a\n//? string — it cannot import from `@luckystack/core`, so it carries its own\n//? minimal `escapeHtml` mirror of core's escaping. Output is byte-identical\n//? to the previously inlined script.\n//? Escaping the embedded JSON path's less-than sign to its unicode escape so a\n//? jsonPath (consumer-configured routePath) containing a closing script-tag\n//? sequence cannot terminate this inline script block and inject markup.\n//? JSON.stringify alone escapes quotes but not that sequence — standard\n//? JSON-in-HTML hardening. (Comment kept OUTSIDE the template literal so it\n//? doesn't itself land in the emitted HTML.)\n//? The 6-char sequence backslash-u-003c (the backslash built from code point 0x5c\n//? to avoid an escaped-backslash string literal). Emitted into the JS so the browser\n//? parses it back to `<` at runtime while the HTML parser never sees a literal `<`\n//? (so a closing script-tag sequence inside jsonPath can't break out of the block).\nconst LT_UNICODE_ESCAPE = String.fromCodePoint(0x5C) + 'u003c';\nconst renderDocsScript = (jsonPath: string, tryItOutData: string): string => `<script>\n const JSON_PATH = ${JSON.stringify(jsonPath).replaceAll('<', LT_UNICODE_ESCAPE)};\n const ENABLE_TRY_IT_OUT = ${tryItOutData};\n const stateByKey = new Map();\n\n //? Page-session CSRF token cache. In the default cookie-mode every\n //? state-changing request (POST/PUT/DELETE) must echo the token from\n //? GET /auth/csrf as the x-csrf-token header, or the server 403s it. We\n //? fetch once and reuse; a failed fetch leaves the cache null so a later\n //? attempt can retry rather than poisoning the session.\n let cachedCsrfToken = null;\n const getCsrfToken = async () => {\n if (cachedCsrfToken !== null) return cachedCsrfToken;\n try {\n const r = await fetch('/auth/csrf', { credentials: 'include' });\n if (!r.ok) return null;\n const body = await r.json();\n cachedCsrfToken = (body && body.csrfToken) ? body.csrfToken : null;\n } catch {\n cachedCsrfToken = null;\n }\n return cachedCsrfToken;\n };\n\n //? Inline runner: hits the live server using the same fetch transport\n //? that the framework apiRequest helper uses. Auth comes from the\n //? browser's existing session cookie or sessionStorage; no token prompt.\n const runEndpoint = async (button, route, method, dataField) => {\n //? The framework enforces the route's declared HTTP method (405 on\n //? mismatch), so the runner must send that method, not a hardcoded POST.\n const httpMethod = (method || 'POST').toUpperCase();\n const hasBody = httpMethod !== 'GET' && httpMethod !== 'DELETE';\n const isStateChanging = httpMethod !== 'GET';\n let parsed;\n try {\n parsed = dataField.value.trim().length === 0 ? {} : JSON.parse(dataField.value);\n } catch (err) {\n const resultEl = button.parentElement.querySelector('pre.result');\n resultEl.textContent = 'JSON parse error: ' + err.message;\n return;\n }\n button.disabled = true;\n const resultEl = button.parentElement.querySelector('pre.result');\n resultEl.textContent = 'Sending...';\n try {\n //? GET/DELETE carry no body — serialize the parsed input into the query\n //? string instead so the user's input is not silently dropped. Non-string\n //? values are JSON-encoded so objects/numbers survive the round-trip.\n const params = new URLSearchParams();\n params.set('stream', 'false');\n if (!hasBody) {\n for (const [k, v] of Object.entries(parsed)) {\n params.set(k, typeof v === 'string' ? v : JSON.stringify(v));\n }\n }\n const headers = hasBody ? { 'Content-Type': 'application/json' } : {};\n //? Attach the CSRF token to state-changing requests (cookie-mode default).\n if (isStateChanging) {\n const csrf = await getCsrfToken();\n if (csrf) headers['x-csrf-token'] = csrf;\n }\n const response = await fetch('/' + route + '?' + params.toString(), {\n method: httpMethod,\n headers,\n credentials: 'include',\n body: hasBody ? JSON.stringify(parsed) : undefined,\n });\n const text = await response.text();\n try {\n resultEl.textContent = JSON.stringify(JSON.parse(text), null, 2);\n } catch {\n resultEl.textContent = text;\n }\n } catch (err) {\n resultEl.textContent = 'Network error: ' + err.message;\n } finally {\n button.disabled = false;\n }\n };\n\n const renderTryItOut = (route, method) => {\n if (!ENABLE_TRY_IT_OUT) return '';\n return '<div class=\"try-it-out\">' +\n '<div style=\"font-weight:600;font-size:12px;margin-bottom:4px;\">Try it out (live request)</div>' +\n '<textarea placeholder=\\\\'{\"key\":\"value\"}\\\\'>{}</textarea>' +\n '<button onclick=\"runEndpoint(this,\\\\'' + escapeHtml(route) + '\\\\',\\\\'' + escapeHtml(method) + '\\\\',this.previousElementSibling)\">Send</button>' +\n '<pre class=\"result\"></pre>' +\n '</div>';\n };\n\n const escapeHtml = (str) => String(str).replace(/[&<>\"']/g, (c) => ({\n '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": '''\n })[c]);\n\n const renderAuth = (auth) => {\n if (!auth) return '<span class=\"auth-tag\">public</span>';\n const tags = [];\n if (auth.login) tags.push('login required');\n if (Array.isArray(auth.additional)) {\n for (const rule of auth.additional) {\n if (rule && rule.key) tags.push(rule.key + (rule.value !== undefined ? '=' + JSON.stringify(rule.value) : ''));\n }\n }\n if (tags.length === 0) tags.push('public');\n return tags.map(t => '<span class=\"auth-tag\">' + escapeHtml(t) + '</span>').join('');\n };\n\n const renderEndpoint = (entry) => {\n const page = entry.page || '';\n const name = entry.name || '';\n const version = entry.version || '';\n const key = page + '/' + name + '@' + version;\n const method = (entry.method || 'POST').toUpperCase();\n const methodClass = ['GET', 'POST', 'PUT', 'DELETE'].includes(method) ? method : 'POST';\n const rate = entry.rateLimit;\n const rateLabel = rate === false ? 'no rate limit' : rate === undefined ? 'default rate' : (rate + '/min');\n const isOpen = stateByKey.get(key) === true;\n const path = '/api/' + page + '/' + name + '/' + version;\n const meta = entry.meta || {};\n return \\`\n <div class=\"endpoint \\${isOpen ? 'open' : ''}\" data-key=\"\\${escapeHtml(key)}\">\n <div class=\"endpoint-summary\">\n <span class=\"method \\${methodClass}\">\\${escapeHtml(method)}</span>\n <span class=\"endpoint-name\">\\${escapeHtml(path)}</span>\n <span class=\"endpoint-meta\">\\${escapeHtml(rateLabel)}</span>\n </div>\n <div class=\"endpoint-detail\">\n <div class=\"detail-section\">\n <div class=\"detail-label\">auth</div>\n <div>\\${renderAuth(entry.auth)}</div>\n </div>\n <div class=\"detail-section\">\n <div class=\"detail-label\">input</div>\n <pre>\\${escapeHtml(entry.input || '{}')}</pre>\n </div>\n <div class=\"detail-section\">\n <div class=\"detail-label\">output</div>\n <pre>\\${escapeHtml(entry.output || 'unknown')}</pre>\n </div>\n \\${entry.stream && entry.stream !== 'never' ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">stream</div>\n <pre>\\${escapeHtml(entry.stream)}</pre>\n </div>\n \\` : ''}\n \\${meta.owner ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">owner</div>\n <div>\\${escapeHtml(meta.owner)}</div>\n </div>\n \\` : ''}\n \\${Array.isArray(meta.tags) && meta.tags.length ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">tags</div>\n <div>\\${meta.tags.map(t => '<span class=\"badge\">' + escapeHtml(String(t)) + '</span>').join(' ')}</div>\n </div>\n \\` : ''}\n \\${meta.deprecated ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">deprecated</div>\n <div style=\"color:var(--delete);\">\\${escapeHtml(String(meta.deprecated))}</div>\n </div>\n \\` : ''}\n \\${renderTryItOut('api/' + page + '/' + name + '/' + version, method)}\n </div>\n </div>\n \\`;\n };\n\n const renderSyncEntry = (entry) => {\n const page = entry.page || '';\n const name = entry.name || '';\n const version = entry.version || '';\n const key = 'sync:' + page + '/' + name + '@' + version;\n const isOpen = stateByKey.get(key) === true;\n const path = 'sync/' + page + '/' + name + '/' + version;\n const meta = entry.meta || {};\n const section = (label, value) => value && value !== 'never'\n ? '<div class=\"detail-section\"><div class=\"detail-label\">' + escapeHtml(label) + '</div><pre>' + escapeHtml(value) + '</pre></div>'\n : '';\n return \\`\n <div class=\"endpoint \\${isOpen ? 'open' : ''}\" data-key=\"\\${escapeHtml(key)}\">\n <div class=\"endpoint-summary\">\n <span class=\"method SYNC\">SYNC</span>\n <span class=\"endpoint-name\">\\${escapeHtml(path)}</span>\n </div>\n <div class=\"endpoint-detail\">\n \\${section('client input', entry.clientInput)}\n \\${section('server output', entry.serverOutput)}\n \\${section('client output', entry.clientOutput)}\n \\${section('server stream', entry.serverStream)}\n \\${section('client stream', entry.clientStream)}\n \\${meta.owner ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">owner</div>\n <div>\\${escapeHtml(meta.owner)}</div>\n </div>\n \\` : ''}\n </div>\n </div>\n \\`;\n };\n\n const passesFilter = (filter, page, name) => {\n if (!filter) return true;\n const haystack = (page + '/' + name).toLowerCase();\n return haystack.includes(filter.toLowerCase());\n };\n\n //? Pure (DOM-free) core: walks the FLAT \\`apis[page] = Entry[]\\` and\n //? \\`syncs[page] = SyncEntry[]\\` maps (the real emitted artifact shape),\n //? builds the grouped endpoint markup, and tallies the summary counts.\n //? Extracted from \\`render\\` so the orchestrator stays a thin DOM shell and\n //? the assembly logic can be reasoned about / tested in isolation.\n const buildGroups = (apis, syncs, filter) => {\n let total = 0;\n let visible = 0;\n const groups = [];\n for (const [page, entries] of Object.entries(apis || {})) {\n const rows = [];\n for (const entry of (Array.isArray(entries) ? entries : [])) {\n total++;\n if (!passesFilter(filter, page, entry.name || '')) continue;\n visible++;\n rows.push(renderEndpoint(entry || {}));\n }\n if (rows.length > 0) {\n groups.push(\\`\n <div class=\"group\">\n <div class=\"group-header\">\\${escapeHtml(page)}</div>\n \\${rows.join('')}\n </div>\n \\`);\n }\n }\n for (const [page, entries] of Object.entries(syncs || {})) {\n const rows = [];\n for (const entry of (Array.isArray(entries) ? entries : [])) {\n total++;\n if (!passesFilter(filter, page, entry.name || '')) continue;\n visible++;\n rows.push(renderSyncEntry(entry || {}));\n }\n if (rows.length > 0) {\n groups.push(\\`\n <div class=\"group\">\n <div class=\"group-header\">\\${escapeHtml(page)} (sync)</div>\n \\${rows.join('')}\n </div>\n \\`);\n }\n }\n const pageCount = new Set([...Object.keys(apis || {}), ...Object.keys(syncs || {})]).size;\n const summaryHtml = \\`\n <div class=\"summary-pill\"><strong>\\${visible}</strong> of <strong>\\${total}</strong> endpoints</div>\n <div class=\"summary-pill\"><strong>\\${pageCount}</strong> pages</div>\n \\`;\n const contentHtml = groups.join('') || '<div class=\"empty\">No matches.</div>';\n return { summaryHtml, contentHtml };\n };\n\n //? Event-delegation toggle: one listener on the #content container handles\n //? all endpoint clicks. This replaces the per-element re-bind that was\n //? called on every filter keystroke (DOCSUI-O13).\n const bindEndpointToggles = () => {\n const content = document.getElementById('content');\n content.addEventListener('click', (e) => {\n const el = e.target.closest('.endpoint');\n if (!el) return;\n const key = el.getAttribute('data-key');\n const next = !el.classList.contains('open');\n stateByKey.set(key, next);\n el.classList.toggle('open');\n });\n };\n\n //? Thin orchestrator: resolve the DOM targets, guard the data shape, then\n //? delegate assembly to \\`buildGroups\\`. Toggles are wired once via delegation.\n const render = (data, filter) => {\n const summary = document.getElementById('summary');\n const content = document.getElementById('content');\n //? Accept both the new artifact shape \\`{apis,syncs}\\` and the legacy bare\n //? map \\`{page:Entry[]}\\`. Warn in dev so consumers migrate (DOCSUI-O12).\n const apis = data && data.apis ? data.apis : data;\n const syncs = data && data.syncs ? data.syncs : {};\n if (data && !data.apis && typeof data === 'object' && typeof console !== 'undefined') {\n console.warn('[docs-ui] legacy artifact shape detected (bare map). Re-generate apiDocs.generated.json to get the {apis,syncs} shape.');\n }\n if (!apis || typeof apis !== 'object') {\n content.innerHTML = '<div class=\"empty\">No API docs available. Run <code>npm run generateArtifacts</code> to generate them.</div>';\n summary.innerHTML = '';\n return;\n }\n const built = buildGroups(apis, syncs, filter);\n summary.innerHTML = built.summaryHtml;\n content.innerHTML = built.contentHtml;\n };\n\n let cachedData = null;\n //? Delegation is registered once; filter keystrokes only rebuild innerHTML.\n bindEndpointToggles();\n document.getElementById('filter').addEventListener('input', (e) => {\n if (cachedData) render(cachedData, e.target.value);\n });\n\n //? On non-OK responses, try to surface the JSON error body's \\`error\\` field\n //? rather than just the raw status code (DOCSUI-O14).\n fetch(JSON_PATH, { credentials: 'include' })\n .then(r => r.ok ? r.json() : r.json().then(\n (j) => Promise.reject(new Error((j && j.error) ? j.error : String(r.status))),\n () => Promise.reject(new Error(String(r.status)))\n ))\n .then(data => { cachedData = data; render(data, ''); })\n .catch(err => {\n document.getElementById('content').innerHTML =\n '<div class=\"empty\">Could not load API docs: ' + escapeHtml(err.message) + '</div>';\n });\n</script>`;\n\nexport const renderDocsHtml = (\n jsonPath: string,\n pageTitle: string,\n options: RenderDocsHtmlOptions = {},\n): string => {\n const branding = options.branding ?? {};\n //? Sanitize CSS-injected values to prevent rule-set / style-element breakout\n //? (DOCSUI-O9). Sanitize logoUrl scheme to block javascript:/data: XSS (DOCSUI-O11).\n const accent = sanitizeCssValue(branding.brandColor ?? '#58a6ff');\n const fontFamily = sanitizeCssValue(branding.fontFamily ?? `system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif`);\n const logoMarkup = branding.logoUrl && isSafeLogoUrl(branding.logoUrl)\n ? `<img src=\"${escapeHtml(branding.logoUrl)}\" alt=\"logo\" style=\"height:32px;width:auto;margin-right:12px;\" />`\n : '';\n const tryItOutData = options.enableTryItOut ? 'true' : 'false';\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>${escapeHtml(pageTitle)}</title>\n${renderDocsCss(accent, fontFamily)}\n</head>\n<body>\n<div class=\"layout\">\n <div class=\"brand-row\">${logoMarkup}<h1>${escapeHtml(pageTitle)}</h1></div>\n <p class=\"lead\">Generated from <code>apiDocs.generated.json</code>. Raw JSON: <a class=\"json-link\" href=\"${escapeHtml(jsonPath)}\">${escapeHtml(jsonPath)}</a></p>\n <div class=\"summary\" id=\"summary\"></div>\n <div class=\"filter-bar\">\n <input type=\"search\" id=\"filter\" placeholder=\"Filter by route name…\" autofocus />\n </div>\n <div id=\"content\"></div>\n</div>\n${renderDocsScript(jsonPath, tryItOutData)}\n</body>\n</html>`;\n};\n"],"mappings":"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","names":[]}
|
package/dist/index.js
CHANGED
package/dist/register.js
CHANGED
package/docs/extension-fields.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
This document covers:
|
|
8
8
|
|
|
9
9
|
- The top-level JSON shape (including the legacy fallback).
|
|
10
|
-
- Core fields the renderer reads
|
|
10
|
+
- Core fields the renderer reads at the top level of each endpoint entry.
|
|
11
11
|
- Optional extension fields rendered when present (`stream`, `owner`, `tags`, `deprecated`).
|
|
12
12
|
- How to add your own metadata via the devkit emitter hooks.
|
|
13
13
|
|
|
@@ -15,30 +15,48 @@ For the rendering pipeline that consumes these fields see `./html-generation.md`
|
|
|
15
15
|
|
|
16
16
|
## Top-level JSON shape
|
|
17
17
|
|
|
18
|
-
The current artifact is
|
|
18
|
+
The current artifact has two top-level maps — `apis` and `syncs` — each keyed by `page`. The value under each page is a **flat array** of endpoint entries (not a nested `name → version` object). Every entry carries its own `name`, `version`, and `path`:
|
|
19
19
|
|
|
20
20
|
```jsonc
|
|
21
21
|
{
|
|
22
22
|
"apis": {
|
|
23
|
-
"<page>":
|
|
24
|
-
|
|
25
|
-
"<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
23
|
+
"<page>": [
|
|
24
|
+
{
|
|
25
|
+
"page": "<page>",
|
|
26
|
+
"name": "<name>",
|
|
27
|
+
"version": "<version>",
|
|
28
|
+
"method": "POST",
|
|
29
|
+
"auth": { "login": true, "additional": [] },
|
|
30
|
+
"rateLimit": 60,
|
|
31
|
+
"input": "{ userId: string }",
|
|
32
|
+
"output": "{ status: 'success', result: { ... } }",
|
|
33
|
+
"stream": "never",
|
|
34
|
+
"path": "api/<page>/<name>/<version>",
|
|
35
|
+
"meta": { "owner": "@team", "tags": ["internal"] }
|
|
33
36
|
}
|
|
34
|
-
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"syncs": {
|
|
40
|
+
"<page>": [
|
|
41
|
+
{
|
|
42
|
+
"page": "<page>",
|
|
43
|
+
"name": "<name>",
|
|
44
|
+
"version": "<version>",
|
|
45
|
+
"clientInput": "{ ... }",
|
|
46
|
+
"serverOutput": "{ ... }",
|
|
47
|
+
"clientOutput": "{ ... }",
|
|
48
|
+
"serverStream": "never",
|
|
49
|
+
"clientStream": "never",
|
|
50
|
+
"path": "sync/<page>/<name>/<version>"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
35
53
|
}
|
|
36
54
|
}
|
|
37
55
|
```
|
|
38
56
|
|
|
39
57
|
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
58
|
|
|
41
|
-
The grouping
|
|
59
|
+
The grouping is `page` → array of entries; `name`, `version`, `method`, `path`, etc. are fields ON each entry. This matches the file-based routing convention (`src/<page>/_api/<name>_v<version>.ts`). The renderer iterates the pages in object-iteration order and each page's array in array order; the emitter is responsible for any sorting.
|
|
42
60
|
|
|
43
61
|
## Core fields (always read by the renderer)
|
|
44
62
|
|
|
@@ -46,11 +64,11 @@ These are the fields used to build the always-visible row + the default expanded
|
|
|
46
64
|
|
|
47
65
|
| Field | Type | Default in renderer | Purpose |
|
|
48
66
|
| --- | --- | --- | --- |
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
67
|
+
| `method` | `'GET' \| 'POST' \| 'PUT' \| 'DELETE'` (any string, uppercased) | `'POST'` | Method pill text + CSS class on `.method`. |
|
|
68
|
+
| `rateLimit` | `number \| false \| undefined` | `'default rate'` label | Numeric ⇒ `"<n>/min"`. `false` ⇒ `"no rate limit"`. `undefined` ⇒ `"default rate"`. |
|
|
69
|
+
| `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. |
|
|
70
|
+
| `input` | `string` (already-stringified TS shape) | `'{}'` | Rendered inside `<pre>` in the expanded panel. |
|
|
71
|
+
| `output` | `string` (already-stringified TS shape) | `'unknown'` | Rendered inside `<pre>` in the expanded panel. |
|
|
54
72
|
|
|
55
73
|
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
74
|
|
|
@@ -60,8 +78,9 @@ Unknown core fields fall through to their defaults silently; emit a missing `met
|
|
|
60
78
|
|
|
61
79
|
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
80
|
|
|
63
|
-
### `
|
|
81
|
+
### `stream` (top-level)
|
|
64
82
|
|
|
83
|
+
- Where: a **top-level** field on the entry (a sibling of `input` / `output`), **not** under `meta`. The renderer reads `entry.stream` and shows the section only when the value is truthy and not the `"never"` sentinel.
|
|
65
84
|
- Type: `string` (already-stringified TS shape, same convention as `input` / `output`).
|
|
66
85
|
- Renders as: a `stream` detail section with the value inside a `<pre>` block.
|
|
67
86
|
- 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.
|
|
@@ -130,7 +149,7 @@ Emission steps the devkit performs per endpoint:
|
|
|
130
149
|
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
150
|
2. Stringify the types into source-faithful strings — no re-stringification, no JSON.stringify of types.
|
|
132
151
|
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`
|
|
152
|
+
4. Group by `page` (one flat array of entries per page, each entry carrying its own `name` / `version` / `path`) and write the JSON.
|
|
134
153
|
|
|
135
154
|
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
155
|
|
package/docs/html-generation.md
CHANGED
|
@@ -93,21 +93,22 @@ The embedded `<script>` runs the following sequence after the HTML parses:
|
|
|
93
93
|
|
|
94
94
|
### `render(data, filter)`
|
|
95
95
|
|
|
96
|
-
- Accepts either `{ apis
|
|
97
|
-
-
|
|
96
|
+
- Accepts either `{ apis, syncs }` (current shape) or a bare `{ [page]: Entry[] }` (legacy shape) — the renderer normalizes via `data && data.apis ? data.apis : data` (and `data.syncs` ?? `{}`), then delegates to `buildGroups(apis, syncs, filter)`.
|
|
97
|
+
- `buildGroups` walks each page's flat array of entries (and the parallel `syncs` map the same way), increments `total`, applies `passesFilter`, and accumulates HTML rows via `renderEndpoint` / `renderSyncEntry`.
|
|
98
98
|
- Pages that have zero matching rows are omitted entirely (no empty `.group` boxes).
|
|
99
99
|
- Writes the `summary-pill` counts (`visible of total`, `pages count`) and the joined `.group` HTML into `<div id="content">`.
|
|
100
|
-
-
|
|
100
|
+
- Open/closed state is toggled by a single delegated `click` listener on `#content` (registered once via `bindEndpointToggles`, not re-bound per render), persisting state in `stateByKey` (keyed by `page + '/' + name + '@' + version`). The state is in-memory only; a hard reload resets all panels to closed.
|
|
101
101
|
|
|
102
|
-
### `renderEndpoint(
|
|
102
|
+
### `renderEndpoint(entry)`
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
Takes a single entry object and pulls these fields directly off it:
|
|
105
105
|
|
|
106
106
|
- `method` — uppercased and used as both the class and the label of the pill. Falls back to `POST` when undefined.
|
|
107
107
|
- `rateLimit` — `false` ⇒ "no rate limit"; `undefined` ⇒ "default rate"; number ⇒ "`<n>`/min".
|
|
108
108
|
- `auth` — passed to `renderAuth`. `auth.login` becomes `"login required"`. Each `auth.additional[]` entry contributes a `"<key>"` or `"<key>=<value>"` badge (value is `JSON.stringify`'d). Empty auth renders a `"public"` badge.
|
|
109
109
|
- `input`, `output` — already-stringified shapes from the type-map emitter. Rendered inside `<pre>` blocks. The fallbacks are `'{}'` and `'unknown'` respectively.
|
|
110
|
-
- `stream
|
|
110
|
+
- `stream` — a top-level field on the entry, rendered when truthy and not `'never'`.
|
|
111
|
+
- `meta.owner`, `meta.tags`, `meta.deprecated` — optional fields under the entry's `meta` object, rendered conditionally. See `./extension-fields.md`.
|
|
111
112
|
|
|
112
113
|
The displayed endpoint path is `/api/<page>/<name>/<version>`; the path used by the runner is `<page>/<name>/<version>` posted to `/<route>?stream=false` (no leading `/api/`).
|
|
113
114
|
|
package/docs/mounting.md
CHANGED
|
@@ -30,6 +30,7 @@ export interface MountDocsUiOptions {
|
|
|
30
30
|
branding?: DocsBranding;
|
|
31
31
|
template?: DocsTemplateBuilder;
|
|
32
32
|
enableTryItOut?: boolean;
|
|
33
|
+
authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
|
|
33
34
|
}
|
|
34
35
|
```
|
|
35
36
|
|
|
@@ -44,6 +45,7 @@ export interface MountDocsUiOptions {
|
|
|
44
45
|
| `branding` | `DocsBranding` | `{}` | Optional logo / accent color / font family. Applied by the default template only. See `./theming.md`. |
|
|
45
46
|
| `template` | `DocsTemplateBuilder` | `undefined` | Custom HTML builder. When provided, the default `renderDocsHtml` is bypassed entirely. See `./html-generation.md` and `./theming.md`. |
|
|
46
47
|
| `enableTryItOut` | `boolean` | `false` | Renders an inline request runner under each endpoint. Off by default — the runner needs a logged-in browser session because it hits the live server with `credentials: 'include'`. |
|
|
48
|
+
| `authorize` | `(req: IncomingMessage) => boolean \| Promise<boolean>` | `undefined` | Optional per-request authorization hook. Called after the env/bind-address gate passes (so it only runs when the route is being served). Return `true` to allow the request, `false` to serve `403 Forbidden`. Use to restrict docs access to authenticated or IP-allowlisted callers on non-loopback deployments (e.g. an internal developer-portal opened with `enabledInProd: true`). |
|
|
47
49
|
|
|
48
50
|
All options are individually optional; calling `mountDocsUi()` with no arguments is valid and produces the default behavior.
|
|
49
51
|
|
|
@@ -119,14 +121,15 @@ The handler's branches map directly to the request shape. The order is exactly a
|
|
|
119
121
|
| Step | Condition | Result |
|
|
120
122
|
| --- | --- | --- |
|
|
121
123
|
| 1 | `pathOnly !== routePath && pathOnly !== ${routePath}/api.json` | Returns `false` (pass-through). No response written. |
|
|
122
|
-
| 2 | `NODE_ENV === 'production'` and `!options.enabledInProd` | `404 Not Found` (`text/plain`). Returns `true`. |
|
|
123
|
-
| 3 | `
|
|
124
|
-
| 4 | `
|
|
125
|
-
| 5 | `pathOnly === ${routePath}/api.json` and the file is
|
|
126
|
-
| 6 | `pathOnly === routePath` and
|
|
127
|
-
| 7 | `pathOnly === routePath` and
|
|
124
|
+
| 2 | (`NODE_ENV === 'production'` **or** `!isLoopbackIp(getBindAddress().ip)`) and `!options.enabledInProd` | `404 Not Found` (`text/plain`). Returns `true`. The non-loopback bind-address check means a server bound to a public interface is gated even outside `NODE_ENV=production`. |
|
|
125
|
+
| 3 | `options.authorize` is set and returns/resolves `false` | `403 Forbidden` (`text/plain`). Returns `true`. Runs only after the env/bind gate (step 2) passes. |
|
|
126
|
+
| 4 | `req.method !== 'GET'` | `405 Method Not Allowed` (`text/plain`). Returns `true`. |
|
|
127
|
+
| 5 | `pathOnly === ${routePath}/api.json` and the file is readable | `200 OK` (`application/json`, `Cache-Control: no-store`) with the raw file contents. Returns `true`. |
|
|
128
|
+
| 6 | `pathOnly === ${routePath}/api.json` and the file is missing/unreadable | `404 Not Found` (`application/json`) with `{ error, expectedAt, hint }`. Returns `true`. |
|
|
129
|
+
| 7 | `pathOnly === routePath` and `options.template` is set | `200 OK` (`text/html; charset=utf-8`) with the template builder's output. Returns `true`. |
|
|
130
|
+
| 8 | `pathOnly === routePath` and no template | `200 OK` (`text/html; charset=utf-8`) with `renderDocsHtml(...)`. Returns `true`. |
|
|
128
131
|
|
|
129
|
-
The order is critical:
|
|
132
|
+
The order is critical: the env/bind gate runs before the `authorize` hook and before method-checking, so a `POST /_docs` in production (or on a public-bind server) still returns `404` — it's gated first — not `405`, and `authorize` is never consulted on a gated request. The gate fires on `NODE_ENV === 'production'` **or** a non-loopback bind address (`!isLoopbackIp(getBindAddress().ip)`), so a staging/preview server bound to a public interface is locked down even when `NODE_ENV` is not `production`; set `enabledInProd: true` to serve the route there. The match itself ignores query strings (the path is split on `?` before comparing) and is strict on trailing slashes — `/_docs/` does NOT match the default route.
|
|
130
133
|
|
|
131
134
|
## Response shapes
|
|
132
135
|
|
|
@@ -149,7 +152,7 @@ HTTP/1.1 200 OK
|
|
|
149
152
|
Content-Type: application/json
|
|
150
153
|
Cache-Control: no-store
|
|
151
154
|
|
|
152
|
-
{ "apis": { "<page>": { "<name>":
|
|
155
|
+
{ "apis": { "<page>": [ { "name": "<name>", "version": "<version>", "method": "POST", ... } ] }, "syncs": { "<page>": [ { ... } ] } }
|
|
153
156
|
```
|
|
154
157
|
|
|
155
158
|
The file is streamed as-is from disk; the handler does not parse, validate, or transform it. Forward-compatible extension fields (see `./extension-fields.md`) pass through unchanged.
|
|
@@ -169,6 +172,8 @@ Content-Type: application/json
|
|
|
169
172
|
|
|
170
173
|
This is intentionally a structured payload so the client-side renderer can surface a helpful empty state. `Cache-Control` is omitted on the error path — the request should be retried after running the artifact generator.
|
|
171
174
|
|
|
175
|
+
`expectedAt` is **dev-only**: it exposes the absolute filesystem path and is omitted when `NODE_ENV === 'production'` to avoid leaking internal directory structure (DOCSUI-8). In production the payload contains only `error` and `hint`.
|
|
176
|
+
|
|
172
177
|
### Production lockdown (`enabledInProd: false`)
|
|
173
178
|
|
|
174
179
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckystack/docs-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -55,10 +55,10 @@
|
|
|
55
55
|
"test": "vitest run"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@luckystack/core": "^0.
|
|
58
|
+
"@luckystack/core": "^0.3.0"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@luckystack/server": "^0.
|
|
61
|
+
"@luckystack/server": "^0.3.0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependenciesMeta": {
|
|
64
64
|
"@luckystack/server": {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/docsHtml.ts"],"sourcesContent":["//? Dev-only API docs UI for LuckyStack.\n//?\n//? Mounts a single page at `/_docs` (configurable) that fetches the\n//? framework's `apiDocs.generated.json` and renders every API endpoint\n//? grouped by page, with method, auth, rate limit, input shape, and\n//? output shape.\n//?\n//? Usage:\n//? // luckystack/docs-ui/index.ts\n//? import { mountDocsUi } from '@luckystack/docs-ui';\n//? mountDocsUi(); // Auto-disables in production unless { enabledInProd: true }\n//?\n//? The mount function returns a `customRoutes` handler — pass it to\n//? `createLuckyStackServer({ customRoutes })` (or compose with another\n//? handler if you have one). It honors the request only when the URL\n//? matches the configured `routePath`; everything else falls through.\n\nimport fs from 'node:fs/promises';\nimport type { IncomingMessage, ServerResponse } from 'node:http';\nimport { getBindAddress, getGeneratedApiDocsPath, isLoopbackIp, tryCatch } from '@luckystack/core';\nimport { renderDocsHtml } from './docsHtml';\n\nexport interface DocsBranding {\n /**\n * Logo URL shown in the header. Accepts `https:`, `http:`, and scheme-free\n * relative URLs. `data:` and `javascript:` URLs are rejected (the logo will\n * be silently omitted) because SVG data-URIs can carry script.\n */\n logoUrl?: string;\n /** CSS color for the header / accent (`#hex` or `rgb()` literal). */\n brandColor?: string;\n /** Web-safe font family for the page. */\n fontFamily?: string;\n}\n\n/**\n * Custom HTML builder. Receives the JSON-endpoint path the page should\n * fetch from + page title + branding; returns the full HTML document\n * (`<!doctype html>` + everything). Use to swap layout entirely (sidebar\n * vs tabs, dark/light theme, embedded auth-token field).\n */\nexport type DocsTemplateBuilder = (input: {\n jsonPath: string;\n pageTitle: string;\n branding: DocsBranding;\n}) => string;\n\nexport interface MountDocsUiOptions {\n /** Path the docs UI is served from. Default: `/_docs`. */\n routePath?: string;\n /**\n * Page title shown in the header + browser tab. Defaults to\n * \"LuckyStack — API docs\". Pass an explicit string to brand for a consumer.\n */\n pageTitle?: string;\n /**\n * Allow rendering the docs UI in production. Default: false. Useful only\n * when the docs are intentionally public (developer-portal, internal-only\n * deployment, etc.). When false, the route returns 404 in production.\n */\n enabledInProd?: boolean;\n /**\n * Override the path to `apiDocs.generated.json`. Defaults to whatever\n * `getGeneratedApiDocsPath()` resolves to via ProjectConfig.paths.\n */\n apiDocsPath?: string;\n /**\n * Optional branding inputs (logo, brand color, font). Applied by the\n * default template. Custom templates may ignore these.\n */\n branding?: DocsBranding;\n /**\n * Custom HTML template builder. When provided, the default `renderDocsHtml`\n * is bypassed entirely. Use for radically different layouts (sidebar,\n * tabs, dark mode, marketing-page-style). Receives a tested JSON path +\n * title + branding so your template doesn't need to know about\n * `getGeneratedApiDocsPath`.\n */\n template?: DocsTemplateBuilder;\n /**\n * Enable the inline \"try-it-out\" runner. When true, the default template\n * renders a request-input box + send button per endpoint that calls\n * `apiRequest` against the live server. Off by default because the\n * runner needs a logged-in session.\n */\n enableTryItOut?: boolean;\n /**\n * Optional per-request authorization hook. Called after the env/bind-address\n * gate passes. Return `true` to allow the request, `false` to serve 403.\n * Use to restrict docs access to authenticated or IP-allowlisted callers on\n * non-loopback deployments.\n */\n authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;\n}\n\nexport type DocsRouteHandler = (\n req: IncomingMessage,\n res: ServerResponse,\n) => Promise<boolean>;\n\n/**\n * Returns a route handler compatible with `createLuckyStackServer`'s\n * `customRoutes` option. The handler returns `true` (response sent) when\n * the URL matches the configured docs route, `false` otherwise.\n */\nexport const mountDocsUi = (options: MountDocsUiOptions = {}): DocsRouteHandler => {\n const routePath = options.routePath ?? '/_docs';\n const jsonPath = `${routePath}/api.json`;\n\n return async (req, res) => {\n const url = req.url ?? '';\n const [pathOnly] = url.split('?');\n\n if (pathOnly !== routePath && pathOnly !== jsonPath) {\n return false;\n }\n\n //? Fail-closed on production env OR any non-loopback bind address unless the\n //? consumer explicitly opts in. Staging/preview servers that bind to a public\n //? interface must set `enabledInProd` to avoid exposing the docs route.\n const isPublicBind = !isLoopbackIp(getBindAddress().ip);\n if ((process.env.NODE_ENV === 'production' || isPublicBind) && !options.enabledInProd) {\n res.statusCode = 404;\n res.setHeader('Content-Type', 'text/plain');\n res.end('Not Found');\n return true;\n }\n\n if (options.authorize) {\n const allowed = await options.authorize(req);\n if (!allowed) {\n res.statusCode = 403;\n res.setHeader('Content-Type', 'text/plain');\n res.end('Forbidden');\n return true;\n }\n }\n\n if (req.method !== 'GET') {\n res.statusCode = 405;\n res.setHeader('Content-Type', 'text/plain');\n res.setHeader('Allow', 'GET');\n res.end('Method Not Allowed');\n return true;\n }\n\n if (pathOnly === jsonPath) {\n const docsPath = options.apiDocsPath ?? getGeneratedApiDocsPath();\n const [readError, content] = await tryCatch(() => fs.readFile(docsPath, 'utf8'));\n if (readError) {\n res.statusCode = 404;\n res.setHeader('Content-Type', 'application/json; charset=utf-8');\n //? Only expose the absolute filesystem path in non-production envs to\n //? avoid leaking internal directory structure to callers (DOCSUI-8).\n const isDev = process.env.NODE_ENV !== 'production';\n res.end(JSON.stringify({\n error: 'apiDocs.generated.json not found',\n ...(isDev ? { expectedAt: docsPath } : {}),\n hint: 'Run `npm run generateArtifacts` to generate it.',\n }));\n } else {\n //? Validate JSON before serving so a torn/corrupt artifact surfaces a\n //? meaningful 422 rather than a 200 with garbled payload that renders\n //? as \"Could not load API docs\" in the browser with no actionable hint\n //? (DD-DOCSUI-17). We parse but serve the original bytes — no\n //? round-trip serialization that could change whitespace or key order.\n try {\n JSON.parse(content ?? '');\n } catch {\n res.statusCode = 422;\n res.setHeader('Content-Type', 'application/json; charset=utf-8');\n res.end(JSON.stringify({\n error: 'apiDocs.generated.json is not valid JSON (file may be torn/corrupted)',\n hint: 'Run `npm run generateArtifacts` to regenerate it.',\n }));\n return true;\n }\n res.statusCode = 200;\n res.setHeader('Content-Type', 'application/json; charset=utf-8');\n res.setHeader('Cache-Control', 'no-store');\n res.end(content);\n }\n return true;\n }\n\n //? Serve the HTML page itself. Title + branding + custom template\n //? layered: explicit `template` builder wins over the built-in renderer.\n //? Defaults render via `renderDocsHtml(jsonPath, title, { branding, enableTryItOut })`.\n const title = options.pageTitle ?? 'LuckyStack — API docs';\n const branding = options.branding ?? {};\n res.statusCode = 200;\n res.setHeader('Content-Type', 'text/html; charset=utf-8');\n res.setHeader('Cache-Control', 'no-store');\n res.setHeader('X-Content-Type-Options', 'nosniff');\n res.setHeader('X-Frame-Options', 'DENY');\n const html = options.template\n ? options.template({ jsonPath, pageTitle: title, branding })\n : renderDocsHtml(jsonPath, title, {\n branding,\n enableTryItOut: options.enableTryItOut ?? false,\n });\n res.end(html);\n return true;\n };\n};\n","//? Embedded HTML for the docs page. Self-contained — no build-time bundler\n//? needed. Fetches `<routePath>/api.json` at runtime and renders the API\n//? catalog grouped by page. Style mirrors the framework's Tailwind tokens\n//? (background / container / muted / common) loosely so it doesn't look\n//? wildly out of place in projects that haven't customized them.\n\nimport { escapeHtml } from '@luckystack/core';\n\nexport interface RenderDocsHtmlOptions {\n branding?: {\n logoUrl?: string;\n brandColor?: string;\n fontFamily?: string;\n };\n /**\n * When true, the renderer injects an inline \"try-it-out\" form per\n * endpoint. The form calls `apiRequest` from `@luckystack/core` against\n * the live server. Disabled by default (requires a logged-in session).\n */\n enableTryItOut?: boolean;\n}\n\n//? Strip CSS-break characters from a value before injecting it into a\n//? <style> block. Removes `}` (would close the rule-set), `;` (would inject\n//? extra declarations), `<` (would close the <style> element), newlines\n//? (CR/LF — would break out of the declaration into adjacent lines),\n//? `url(` (would allow loading arbitrary external resources), and CSS comment\n//? delimiters `/*` / `*/` (would allow commenting out surrounding rules).\nconst sanitizeCssValue = (value: string): string =>\n value\n .replaceAll('}', '')\n .replaceAll(';', '')\n .replaceAll('<', '')\n .replaceAll('\\n', '')\n .replaceAll('\\r', '')\n .replaceAll('url(', '')\n .replaceAll('/*', '')\n .replaceAll('*/', '');\n\n//? Only allow http: and https: schemes for the logo URL to block javascript:\n//? and data: XSS vectors. data: URLs are explicitly rejected because an SVG\n//? data-URI can carry script.\nconst isSafeLogoUrl = (url: string): boolean => {\n try {\n const parsed = new URL(url);\n return parsed.protocol === 'https:' || parsed.protocol === 'http:';\n } catch {\n // Relative URLs (no scheme) are safe — they resolve on the same origin.\n return !url.includes(':');\n }\n};\n\n//? Renders the `<style>` block. Split out of `renderDocsHtml` so the\n//? document assembler stays readable; `accent` + `fontFamily` are the only\n//? two values interpolated into the stylesheet. Output is byte-identical to\n//? the previously inlined CSS.\nconst renderDocsCss = (accent: string, fontFamily: string): string => `<style>\n :root {\n --bg: #0e1116;\n --container: #161b22;\n --container-hover: #1c232b;\n --border: #2a313c;\n --title: #e6edf3;\n --common: #c9d1d9;\n --muted: #8b949e;\n --accent: ${accent};\n --font-family: ${fontFamily};\n --get: #3fb950;\n --post: ${accent};\n --put: #d29922;\n --delete: #f85149;\n }\n @media (prefers-color-scheme: light) {\n :root {\n --bg: #ffffff;\n --container: #f6f8fa;\n --container-hover: #eaeef2;\n --border: #d0d7de;\n --title: #1f2328;\n --common: #1f2328;\n --muted: #59636e;\n }\n }\n * { box-sizing: border-box; }\n body {\n margin: 0; padding: 0;\n font-family: var(--font-family);\n background: var(--bg); color: var(--common);\n line-height: 1.5;\n }\n .brand-row { display: flex; align-items: center; }\n .try-it-out { margin-top: 12px; padding: 12px; background: var(--container-hover); border: 1px solid var(--border); border-radius: 6px; }\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; }\n .try-it-out button { margin-top: 6px; padding: 6px 12px; background: var(--accent); color: white; border: 0; border-radius: 4px; cursor: pointer; }\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; }\n .layout {\n max-width: 1200px;\n margin: 0 auto;\n padding: 32px 24px 96px;\n }\n h1 { color: var(--title); margin: 0 0 4px; font-size: 28px; }\n .lead { color: var(--muted); margin: 0 0 32px; font-size: 14px; }\n .summary { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }\n .summary-pill {\n background: var(--container);\n border: 1px solid var(--border);\n padding: 8px 14px;\n border-radius: 8px;\n font-size: 13px;\n }\n .summary-pill strong { color: var(--title); }\n .filter-bar {\n margin-bottom: 24px;\n display: flex;\n gap: 12px;\n align-items: center;\n }\n input[type=\"search\"] {\n flex: 1;\n background: var(--container);\n border: 1px solid var(--border);\n color: var(--common);\n padding: 10px 14px;\n border-radius: 8px;\n font-size: 14px;\n font-family: inherit;\n }\n input[type=\"search\"]:focus {\n outline: none;\n border-color: var(--accent);\n }\n .group {\n margin-bottom: 24px;\n background: var(--container);\n border: 1px solid var(--border);\n border-radius: 12px;\n overflow: hidden;\n }\n .group-header {\n padding: 12px 18px;\n background: var(--container-hover);\n font-size: 13px;\n font-weight: 600;\n color: var(--muted);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n border-bottom: 1px solid var(--border);\n }\n .endpoint {\n padding: 14px 18px;\n border-bottom: 1px solid var(--border);\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Consolas, monospace;\n font-size: 13px;\n cursor: pointer;\n transition: background 80ms;\n }\n .endpoint:last-child { border-bottom: none; }\n .endpoint:hover { background: var(--container-hover); }\n .endpoint-summary { display: flex; align-items: center; gap: 12px; }\n .method {\n display: inline-block;\n min-width: 56px;\n padding: 2px 8px;\n border-radius: 4px;\n font-size: 11px;\n font-weight: 700;\n text-align: center;\n color: #fff;\n }\n .method.GET { background: var(--get); }\n .method.POST { background: var(--post); }\n .method.PUT { background: var(--put); color: #1f2328; }\n .method.DELETE { background: var(--delete); }\n .method.SYNC { background: var(--muted); }\n .badge {\n display: inline-block;\n padding: 1px 6px;\n border-radius: 4px;\n background: var(--container-hover);\n color: var(--muted);\n font-size: 11px;\n margin-right: 4px;\n }\n .endpoint-name { color: var(--title); font-weight: 500; }\n .endpoint-meta { margin-left: auto; color: var(--muted); font-size: 12px; }\n .endpoint-detail {\n margin-top: 12px;\n padding: 12px;\n background: var(--bg);\n border-radius: 8px;\n border: 1px solid var(--border);\n display: none;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .endpoint.open .endpoint-detail { display: block; }\n .detail-section { margin-bottom: 12px; }\n .detail-section:last-child { margin-bottom: 0; }\n .detail-label {\n color: var(--muted);\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n margin-bottom: 4px;\n }\n pre {\n margin: 0;\n color: var(--common);\n font-size: 12px;\n overflow-x: auto;\n }\n .auth-tag {\n display: inline-block;\n padding: 1px 6px;\n border-radius: 4px;\n background: var(--container-hover);\n color: var(--muted);\n font-size: 11px;\n margin-right: 4px;\n }\n .empty {\n padding: 48px;\n text-align: center;\n color: var(--muted);\n }\n a.json-link { color: var(--accent); }\n</style>`;\n\n//? Renders the self-contained client `<script>` block. Split out of\n//? `renderDocsHtml` for readability; `jsonPath` (fetch target, injected as a\n//? JSON.stringify literal) and `tryItOutData` (`'true'`/`'false'`) are the\n//? only two interpolations. This is browser-runtime JavaScript embedded as a\n//? string — it cannot import from `@luckystack/core`, so it carries its own\n//? minimal `escapeHtml` mirror of core's escaping. Output is byte-identical\n//? to the previously inlined script.\nconst renderDocsScript = (jsonPath: string, tryItOutData: string): string => `<script>\n const JSON_PATH = ${JSON.stringify(jsonPath)};\n const ENABLE_TRY_IT_OUT = ${tryItOutData};\n const stateByKey = new Map();\n\n //? Inline runner: hits the live server using the same fetch transport\n //? that the framework apiRequest helper uses. Auth comes from the\n //? browser's existing session cookie or sessionStorage; no token prompt.\n const runEndpoint = async (button, route, method, dataField) => {\n //? The framework enforces the route's declared HTTP method (405 on\n //? mismatch), so the runner must send that method, not a hardcoded POST.\n const httpMethod = (method || 'POST').toUpperCase();\n const hasBody = httpMethod !== 'GET' && httpMethod !== 'DELETE';\n let parsed;\n try {\n parsed = dataField.value.trim().length === 0 ? {} : JSON.parse(dataField.value);\n } catch (err) {\n const resultEl = button.parentElement.querySelector('pre.result');\n resultEl.textContent = 'JSON parse error: ' + err.message;\n return;\n }\n button.disabled = true;\n const resultEl = button.parentElement.querySelector('pre.result');\n resultEl.textContent = 'Sending...';\n try {\n const response = await fetch('/' + route + '?stream=false', {\n method: httpMethod,\n headers: hasBody ? { 'Content-Type': 'application/json' } : {},\n credentials: 'include',\n body: hasBody ? JSON.stringify(parsed) : undefined,\n });\n const text = await response.text();\n try {\n resultEl.textContent = JSON.stringify(JSON.parse(text), null, 2);\n } catch {\n resultEl.textContent = text;\n }\n } catch (err) {\n resultEl.textContent = 'Network error: ' + err.message;\n } finally {\n button.disabled = false;\n }\n };\n\n const renderTryItOut = (route, method) => {\n if (!ENABLE_TRY_IT_OUT) return '';\n return '<div class=\"try-it-out\">' +\n '<div style=\"font-weight:600;font-size:12px;margin-bottom:4px;\">Try it out (live request)</div>' +\n '<textarea placeholder=\\\\'{\"key\":\"value\"}\\\\'>{}</textarea>' +\n '<button onclick=\"runEndpoint(this,\\\\'' + escapeHtml(route) + '\\\\',\\\\'' + escapeHtml(method) + '\\\\',this.previousElementSibling)\">Send</button>' +\n '<pre class=\"result\"></pre>' +\n '</div>';\n };\n\n const escapeHtml = (str) => String(str).replace(/[&<>\"']/g, (c) => ({\n '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": '''\n })[c]);\n\n const renderAuth = (auth) => {\n if (!auth) return '<span class=\"auth-tag\">public</span>';\n const tags = [];\n if (auth.login) tags.push('login required');\n if (Array.isArray(auth.additional)) {\n for (const rule of auth.additional) {\n if (rule && rule.key) tags.push(rule.key + (rule.value !== undefined ? '=' + JSON.stringify(rule.value) : ''));\n }\n }\n if (tags.length === 0) tags.push('public');\n return tags.map(t => '<span class=\"auth-tag\">' + escapeHtml(t) + '</span>').join('');\n };\n\n const renderEndpoint = (entry) => {\n const page = entry.page || '';\n const name = entry.name || '';\n const version = entry.version || '';\n const key = page + '/' + name + '@' + version;\n const method = (entry.method || 'POST').toUpperCase();\n const methodClass = ['GET', 'POST', 'PUT', 'DELETE'].includes(method) ? method : 'POST';\n const rate = entry.rateLimit;\n const rateLabel = rate === false ? 'no rate limit' : rate === undefined ? 'default rate' : (rate + '/min');\n const isOpen = stateByKey.get(key) === true;\n const path = '/api/' + page + '/' + name + '/' + version;\n const meta = entry.meta || {};\n return \\`\n <div class=\"endpoint \\${isOpen ? 'open' : ''}\" data-key=\"\\${escapeHtml(key)}\">\n <div class=\"endpoint-summary\">\n <span class=\"method \\${methodClass}\">\\${escapeHtml(method)}</span>\n <span class=\"endpoint-name\">\\${escapeHtml(path)}</span>\n <span class=\"endpoint-meta\">\\${escapeHtml(rateLabel)}</span>\n </div>\n <div class=\"endpoint-detail\">\n <div class=\"detail-section\">\n <div class=\"detail-label\">auth</div>\n <div>\\${renderAuth(entry.auth)}</div>\n </div>\n <div class=\"detail-section\">\n <div class=\"detail-label\">input</div>\n <pre>\\${escapeHtml(entry.input || '{}')}</pre>\n </div>\n <div class=\"detail-section\">\n <div class=\"detail-label\">output</div>\n <pre>\\${escapeHtml(entry.output || 'unknown')}</pre>\n </div>\n \\${entry.stream && entry.stream !== 'never' ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">stream</div>\n <pre>\\${escapeHtml(entry.stream)}</pre>\n </div>\n \\` : ''}\n \\${meta.owner ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">owner</div>\n <div>\\${escapeHtml(meta.owner)}</div>\n </div>\n \\` : ''}\n \\${Array.isArray(meta.tags) && meta.tags.length ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">tags</div>\n <div>\\${meta.tags.map(t => '<span class=\"badge\">' + escapeHtml(String(t)) + '</span>').join(' ')}</div>\n </div>\n \\` : ''}\n \\${meta.deprecated ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">deprecated</div>\n <div style=\"color:var(--delete);\">\\${escapeHtml(String(meta.deprecated))}</div>\n </div>\n \\` : ''}\n \\${renderTryItOut('api/' + page + '/' + name + '/' + version, method)}\n </div>\n </div>\n \\`;\n };\n\n const renderSyncEntry = (entry) => {\n const page = entry.page || '';\n const name = entry.name || '';\n const version = entry.version || '';\n const key = 'sync:' + page + '/' + name + '@' + version;\n const isOpen = stateByKey.get(key) === true;\n const path = 'sync/' + page + '/' + name + '/' + version;\n const meta = entry.meta || {};\n const section = (label, value) => value && value !== 'never'\n ? '<div class=\"detail-section\"><div class=\"detail-label\">' + escapeHtml(label) + '</div><pre>' + escapeHtml(value) + '</pre></div>'\n : '';\n return \\`\n <div class=\"endpoint \\${isOpen ? 'open' : ''}\" data-key=\"\\${escapeHtml(key)}\">\n <div class=\"endpoint-summary\">\n <span class=\"method SYNC\">SYNC</span>\n <span class=\"endpoint-name\">\\${escapeHtml(path)}</span>\n </div>\n <div class=\"endpoint-detail\">\n \\${section('client input', entry.clientInput)}\n \\${section('server output', entry.serverOutput)}\n \\${section('client output', entry.clientOutput)}\n \\${section('server stream', entry.serverStream)}\n \\${section('client stream', entry.clientStream)}\n \\${meta.owner ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">owner</div>\n <div>\\${escapeHtml(meta.owner)}</div>\n </div>\n \\` : ''}\n </div>\n </div>\n \\`;\n };\n\n const passesFilter = (filter, page, name) => {\n if (!filter) return true;\n const haystack = (page + '/' + name).toLowerCase();\n return haystack.includes(filter.toLowerCase());\n };\n\n //? Pure (DOM-free) core: walks the FLAT \\`apis[page] = Entry[]\\` and\n //? \\`syncs[page] = SyncEntry[]\\` maps (the real emitted artifact shape),\n //? builds the grouped endpoint markup, and tallies the summary counts.\n //? Extracted from \\`render\\` so the orchestrator stays a thin DOM shell and\n //? the assembly logic can be reasoned about / tested in isolation.\n const buildGroups = (apis, syncs, filter) => {\n let total = 0;\n let visible = 0;\n const groups = [];\n for (const [page, entries] of Object.entries(apis || {})) {\n const rows = [];\n for (const entry of (Array.isArray(entries) ? entries : [])) {\n total++;\n if (!passesFilter(filter, page, entry.name || '')) continue;\n visible++;\n rows.push(renderEndpoint(entry || {}));\n }\n if (rows.length > 0) {\n groups.push(\\`\n <div class=\"group\">\n <div class=\"group-header\">\\${escapeHtml(page)}</div>\n \\${rows.join('')}\n </div>\n \\`);\n }\n }\n for (const [page, entries] of Object.entries(syncs || {})) {\n const rows = [];\n for (const entry of (Array.isArray(entries) ? entries : [])) {\n total++;\n if (!passesFilter(filter, page, entry.name || '')) continue;\n visible++;\n rows.push(renderSyncEntry(entry || {}));\n }\n if (rows.length > 0) {\n groups.push(\\`\n <div class=\"group\">\n <div class=\"group-header\">\\${escapeHtml(page)} (sync)</div>\n \\${rows.join('')}\n </div>\n \\`);\n }\n }\n const pageCount = new Set([...Object.keys(apis || {}), ...Object.keys(syncs || {})]).size;\n const summaryHtml = \\`\n <div class=\"summary-pill\"><strong>\\${visible}</strong> of <strong>\\${total}</strong> endpoints</div>\n <div class=\"summary-pill\"><strong>\\${pageCount}</strong> pages</div>\n \\`;\n const contentHtml = groups.join('') || '<div class=\"empty\">No matches.</div>';\n return { summaryHtml, contentHtml };\n };\n\n //? Event-delegation toggle: one listener on the #content container handles\n //? all endpoint clicks. This replaces the per-element re-bind that was\n //? called on every filter keystroke (DOCSUI-O13).\n const bindEndpointToggles = () => {\n const content = document.getElementById('content');\n content.addEventListener('click', (e) => {\n const el = e.target.closest('.endpoint');\n if (!el) return;\n const key = el.getAttribute('data-key');\n const next = !el.classList.contains('open');\n stateByKey.set(key, next);\n el.classList.toggle('open');\n });\n };\n\n //? Thin orchestrator: resolve the DOM targets, guard the data shape, then\n //? delegate assembly to \\`buildGroups\\`. Toggles are wired once via delegation.\n const render = (data, filter) => {\n const summary = document.getElementById('summary');\n const content = document.getElementById('content');\n //? Accept both the new artifact shape \\`{apis,syncs}\\` and the legacy bare\n //? map \\`{page:Entry[]}\\`. Warn in dev so consumers migrate (DOCSUI-O12).\n const apis = data && data.apis ? data.apis : data;\n const syncs = data && data.syncs ? data.syncs : {};\n if (data && !data.apis && typeof data === 'object' && typeof console !== 'undefined') {\n console.warn('[docs-ui] legacy artifact shape detected (bare map). Re-generate apiDocs.generated.json to get the {apis,syncs} shape.');\n }\n if (!apis || typeof apis !== 'object') {\n content.innerHTML = '<div class=\"empty\">No API docs available. Run <code>npm run generateArtifacts</code> to generate them.</div>';\n summary.innerHTML = '';\n return;\n }\n const built = buildGroups(apis, syncs, filter);\n summary.innerHTML = built.summaryHtml;\n content.innerHTML = built.contentHtml;\n };\n\n let cachedData = null;\n //? Delegation is registered once; filter keystrokes only rebuild innerHTML.\n bindEndpointToggles();\n document.getElementById('filter').addEventListener('input', (e) => {\n if (cachedData) render(cachedData, e.target.value);\n });\n\n //? On non-OK responses, try to surface the JSON error body's \\`error\\` field\n //? rather than just the raw status code (DOCSUI-O14).\n fetch(JSON_PATH, { credentials: 'include' })\n .then(r => r.ok ? r.json() : r.json().then(\n (j) => Promise.reject(new Error((j && j.error) ? j.error : String(r.status))),\n () => Promise.reject(new Error(String(r.status)))\n ))\n .then(data => { cachedData = data; render(data, ''); })\n .catch(err => {\n document.getElementById('content').innerHTML =\n '<div class=\"empty\">Could not load API docs: ' + escapeHtml(err.message) + '</div>';\n });\n</script>`;\n\nexport const renderDocsHtml = (\n jsonPath: string,\n pageTitle: string,\n options: RenderDocsHtmlOptions = {},\n): string => {\n const branding = options.branding ?? {};\n //? Sanitize CSS-injected values to prevent rule-set / style-element breakout\n //? (DOCSUI-O9). Sanitize logoUrl scheme to block javascript:/data: XSS (DOCSUI-O11).\n const accent = sanitizeCssValue(branding.brandColor ?? '#58a6ff');\n const fontFamily = sanitizeCssValue(branding.fontFamily ?? `system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif`);\n const logoMarkup = branding.logoUrl && isSafeLogoUrl(branding.logoUrl)\n ? `<img src=\"${escapeHtml(branding.logoUrl)}\" alt=\"logo\" style=\"height:32px;width:auto;margin-right:12px;\" />`\n : '';\n const tryItOutData = options.enableTryItOut ? 'true' : 'false';\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>${escapeHtml(pageTitle)}</title>\n${renderDocsCss(accent, fontFamily)}\n</head>\n<body>\n<div class=\"layout\">\n <div class=\"brand-row\">${logoMarkup}<h1>${escapeHtml(pageTitle)}</h1></div>\n <p class=\"lead\">Generated from <code>apiDocs.generated.json</code>. Raw JSON: <a class=\"json-link\" href=\"${escapeHtml(jsonPath)}\">${escapeHtml(jsonPath)}</a></p>\n <div class=\"summary\" id=\"summary\"></div>\n <div class=\"filter-bar\">\n <input type=\"search\" id=\"filter\" placeholder=\"Filter by route name…\" autofocus />\n </div>\n <div id=\"content\"></div>\n</div>\n${renderDocsScript(jsonPath, tryItOutData)}\n</body>\n</html>`;\n};\n"],"mappings":"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","names":[]}
|