@luckystack/docs-ui 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.1] - 2026-07-27
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Production docs exposure and error-detail gates now follow runtime mode instead of deploy-topology identity.
|
|
15
|
+
|
|
10
16
|
## [0.1.0]
|
|
11
17
|
|
|
12
18
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import fs from "fs/promises";
|
|
3
|
-
import { getBindAddress, getGeneratedApiDocsPath, isLoopbackIp,
|
|
3
|
+
import { getBindAddress, getGeneratedApiDocsPath, isLoopbackIp, isProductionRuntime, tryCatch } from "@luckystack/core";
|
|
4
4
|
|
|
5
5
|
// src/docsHtml.ts
|
|
6
6
|
import { escapeHtml } from "@luckystack/core";
|
|
@@ -543,7 +543,7 @@ var mountDocsUi = (options = {}) => {
|
|
|
543
543
|
return false;
|
|
544
544
|
}
|
|
545
545
|
const isPublicBind = !isLoopbackIp(getBindAddress().ip);
|
|
546
|
-
if ((
|
|
546
|
+
if ((isProductionRuntime() || isPublicBind) && !options.enabledInProd) {
|
|
547
547
|
res.statusCode = 404;
|
|
548
548
|
res.setHeader("Content-Type", "text/plain");
|
|
549
549
|
res.end("Not Found");
|
|
@@ -571,7 +571,7 @@ var mountDocsUi = (options = {}) => {
|
|
|
571
571
|
if (readError) {
|
|
572
572
|
res.statusCode = 404;
|
|
573
573
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
574
|
-
const isDev =
|
|
574
|
+
const isDev = !isProductionRuntime();
|
|
575
575
|
res.end(JSON.stringify({
|
|
576
576
|
error: "apiDocs.generated.json not found",
|
|
577
577
|
...isDev ? { expectedAt: docsPath } : {},
|
|
@@ -615,4 +615,4 @@ var mountDocsUi = (options = {}) => {
|
|
|
615
615
|
export {
|
|
616
616
|
mountDocsUi
|
|
617
617
|
};
|
|
618
|
-
//# sourceMappingURL=chunk-
|
|
618
|
+
//# sourceMappingURL=chunk-5CJZTAA5.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, isProductionRuntime, 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 ((isProductionRuntime() || 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 //? Runtime mode is separate from deploy-topology identity.\n const isDev = !isProductionRuntime();\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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckystack/docs-ui",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
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.8.
|
|
58
|
+
"@luckystack/core": "^0.8.1"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@luckystack/server": "^0.8.
|
|
61
|
+
"@luckystack/server": "^0.8.1"
|
|
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, 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":[]}
|