@luckystack/docs-ui 0.10.2 → 0.12.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/AGENTS.md ADDED
@@ -0,0 +1,5 @@
1
+ # @luckystack/docs-ui — agent guidance
2
+
3
+ This is a development documentation surface, not a public API catalog by default. Only expose it in production after an explicit access-control decision; generated endpoint metadata and the optional request runner can disclose information or exercise authenticated endpoints. See `README.md` for a minimal start and `docs/mounting.md` for the access boundary.
4
+
5
+ Escaping endpoint metadata before HTML insertion is a security invariant. Authorization and environment checks belong before serving both page and JSON, and a custom template must uphold its own escaping. Keep this package independent of a React build step; source and generated API docs, not this file, define supported options and field shapes.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,18 @@ All notable changes to `@luckystack/docs-ui` are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.12.0] - 2026-10-15
9
+
10
+ ### Changed
11
+
12
+ - Requires Node.js 22.13 or newer (was 20). Node 20 stopped receiving security updates in April 2026, and the scaffold's lint toolchain (ESLint 10) needs 22.13.
13
+
14
+ ## [0.11.0] - 2026-10-14
15
+
16
+ ### Changed
17
+
18
+ - Type declarations are emitted per source file by TypeScript 7 (`tsc -b`) instead of one bundled `.d.ts` per entry, so the framework build no longer runs package by package. The exported types are unchanged; relative imports inside the declarations carry `.js`, so `moduleResolution: "node16"`/`"nodenext"` projects resolve them as before.
19
+ - Package design guidance now ships as `AGENTS.md`, not `CLAUDE.md`. Handwritten docs focus on rationale and safety; exact API contracts remain in installed declarations/source.
9
20
 
10
21
  ## [0.8.1] - 2026-07-27
11
22
 
package/README.md CHANGED
@@ -1,90 +1,5 @@
1
1
  # @luckystack/docs-ui
2
2
 
3
- Dev-only API docs UI for [LuckyStack](https://github.com/ItsLucky23/LuckyStack-v2).
3
+ A development UI for generated LuckyStack API documentation. In a scaffolded project use `npx luckystack add docs-ui`, or install `@luckystack/docs-ui` and mount its handler through the server's custom routes. Generate API artifacts with the project's `generateArtifacts` script if the UI reports that they are missing. See [mounting](./docs/mounting.md) for the access boundary.
4
4
 
5
- Mounts a single page at `/_docs` that fetches the framework's generated
6
- `apiDocs.generated.json` and renders every API endpoint grouped by page,
7
- with HTTP method, auth requirements, rate limit, input shape, and output
8
- shape.
9
-
10
- ## Install
11
-
12
- ```bash
13
- npm install @luckystack/docs-ui
14
- ```
15
-
16
- Peer deps (already installed if you have any other LuckyStack package):
17
-
18
- - `@luckystack/core`
19
- - `@luckystack/server` (the docs UI is mounted via `createLuckyStackServer`'s `customRoutes` option)
20
-
21
- ## Quickstart
22
-
23
- ```ts
24
- // luckystack/docs-ui/index.ts (overlay file — auto-imported by bootstrapLuckyStack)
25
- import { mountDocsUi } from '@luckystack/docs-ui';
26
-
27
- export const docsUiHandler = mountDocsUi({
28
- // Title shown in the header + browser tab. Defaults to 'LuckyStack — API docs'.
29
- // Pass your project name explicitly — there's no implicit lookup from
30
- // ProjectConfig because consumer config shapes vary too widely to introspect.
31
- pageTitle: 'My App — API docs',
32
- });
33
- ```
34
-
35
- ```ts
36
- // server/server.ts
37
- import { docsUiHandler } from '../luckystack/docs-ui';
38
- import { bootstrapLuckyStack } from '@luckystack/server';
39
- import { serveFile, serveFavicon } from './prod/serveFile';
40
-
41
- await (await bootstrapLuckyStack({
42
- serveFile,
43
- serveFavicon,
44
- customRoutes: docsUiHandler,
45
- })).listen();
46
- ```
47
-
48
- Open `http://localhost/_docs` and you'll see every API in your project,
49
- grouped by page, with one-click expand for each endpoint's input/output.
50
-
51
- ## Production
52
-
53
- By default the route returns 404 in production (`NODE_ENV === 'production'`).
54
- Pass `{ enabledInProd: true }` to render it anyway — useful for an internal
55
- developer-portal deployment, but **never** safe on a public surface unless
56
- your APIs are intentionally documented to the world.
57
-
58
- ## Options
59
-
60
- | Option | Default | Notes |
61
- | --- | --- | --- |
62
- | `routePath` | `/_docs` | Path the page is served from. The JSON is at `${routePath}/api.json`. |
63
- | `pageTitle` | `LuckyStack — API docs` | Shown in the header + browser tab. Pass your project name explicitly — there is no implicit lookup from `ProjectConfig`. |
64
- | `enabledInProd` | `false` | Set to `true` to render the docs in production. |
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. |
70
-
71
- ## How it works
72
-
73
- The docs page is a single embedded HTML file (no build step, no React, no
74
- external dependencies). It fetches `${routePath}/api.json` at runtime,
75
- which is served from the project's `apiDocs.generated.json` — generated by
76
- `@luckystack/devkit`'s type-map emitter on every dev-server reload and on
77
- every `npm run generateArtifacts`.
78
-
79
- The handler is a `customRoutes` predicate: it returns `true` when it
80
- matches the configured paths, `false` otherwise — so it composes cleanly
81
- with any other custom HTTP handler.
82
-
83
- ## Related architecture docs
84
-
85
- - [`docs/ARCHITECTURE_API.md`](../../docs/ARCHITECTURE_API.md) — the lifecycle these endpoints document.
86
- - [`docs/ARCHITECTURE_PACKAGING.md`](../../docs/ARCHITECTURE_PACKAGING.md) — how `apiDocs.generated.json` is produced.
87
-
88
- ## License
89
-
90
- MIT — see the [repository LICENSE](https://github.com/ItsLucky23/LuckyStack-v2/blob/master/LICENSE).
5
+ **Do not expose the documentation route publicly by accident.** Production access is disabled by default; if you intentionally enable it, add authorization and review generated metadata for sensitive detail. The optional interactive request runner uses the browser's credentials and should only be enabled for trusted developers. Custom HTML templates must escape content from generated metadata; see [HTML generation](./docs/html-generation.md).
@@ -186,7 +186,7 @@ var renderDocsCss = (accent, fontFamily) => `<style>
186
186
  </style>`;
187
187
  var LT_UNICODE_ESCAPE = String.fromCodePoint(92) + "u003c";
188
188
  var renderDocsScript = (jsonPath, tryItOutData) => `<script>
189
- const JSON_PATH = ${JSON.stringify(jsonPath).replaceAll("<", LT_UNICODE_ESCAPE)};
189
+ const JSON_PATH = ${JSON.stringify(jsonPath).replaceAll("<", () => LT_UNICODE_ESCAPE)};
190
190
  const ENABLE_TRY_IT_OUT = ${tryItOutData};
191
191
  const stateByKey = new Map();
192
192
 
@@ -615,4 +615,4 @@ var mountDocsUi = (options = {}) => {
615
615
  export {
616
616
  mountDocsUi
617
617
  };
618
- //# sourceMappingURL=chunk-5CJZTAA5.js.map
618
+ //# sourceMappingURL=chunk-AFSFBNYK.js.map
@@ -1 +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 '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;'\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":[]}
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 '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;'\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":[]}
@@ -0,0 +1,14 @@
1
+ export interface RenderDocsHtmlOptions {
2
+ branding?: {
3
+ logoUrl?: string;
4
+ brandColor?: string;
5
+ fontFamily?: string;
6
+ };
7
+ /**
8
+ * When true, the renderer injects an inline "try-it-out" form per
9
+ * endpoint. The form calls `apiRequest` from `@luckystack/core` against
10
+ * the live server. Disabled by default (requires a logged-in session).
11
+ */
12
+ enableTryItOut?: boolean;
13
+ }
14
+ export declare const renderDocsHtml: (jsonPath: string, pageTitle: string, options?: RenderDocsHtmlOptions) => string;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { IncomingMessage, ServerResponse } from 'node:http';
2
-
3
- interface DocsBranding {
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ export interface DocsBranding {
4
3
  /**
5
4
  * Logo URL shown in the header. Accepts `https:`, `http:`, and scheme-free
6
5
  * relative URLs. `data:` and `javascript:` URLs are rejected (the logo will
@@ -18,12 +17,12 @@ interface DocsBranding {
18
17
  * (`<!doctype html>` + everything). Use to swap layout entirely (sidebar
19
18
  * vs tabs, dark/light theme, embedded auth-token field).
20
19
  */
21
- type DocsTemplateBuilder = (input: {
20
+ export type DocsTemplateBuilder = (input: {
22
21
  jsonPath: string;
23
22
  pageTitle: string;
24
23
  branding: DocsBranding;
25
24
  }) => string;
26
- interface MountDocsUiOptions {
25
+ export interface MountDocsUiOptions {
27
26
  /** Path the docs UI is served from. Default: `/_docs`. */
28
27
  routePath?: string;
29
28
  /**
@@ -70,12 +69,10 @@ interface MountDocsUiOptions {
70
69
  */
71
70
  authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
72
71
  }
73
- type DocsRouteHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
72
+ export type DocsRouteHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
74
73
  /**
75
74
  * Returns a route handler compatible with `createLuckyStackServer`'s
76
75
  * `customRoutes` option. The handler returns `true` (response sent) when
77
76
  * the URL matches the configured docs route, `false` otherwise.
78
77
  */
79
- declare const mountDocsUi: (options?: MountDocsUiOptions) => DocsRouteHandler;
80
-
81
- export { type DocsBranding, type DocsRouteHandler, type DocsTemplateBuilder, type MountDocsUiOptions, mountDocsUi };
78
+ export declare const mountDocsUi: (options?: MountDocsUiOptions) => DocsRouteHandler;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  mountDocsUi
3
- } from "./chunk-5CJZTAA5.js";
3
+ } from "./chunk-AFSFBNYK.js";
4
4
  export {
5
5
  mountDocsUi
6
6
  };
@@ -1,2 +1 @@
1
-
2
- export { }
1
+ export {};
package/dist/register.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  mountDocsUi
3
- } from "./chunk-5CJZTAA5.js";
3
+ } from "./chunk-AFSFBNYK.js";
4
4
 
5
5
  // src/register.ts
6
6
  import { registerCustomRoute } from "@luckystack/server";
@@ -1,169 +1,5 @@
1
- # Extension fields on `apiDocs.generated.json`
1
+ # Extending generated API documentation
2
2
 
3
- > Dev-only. These fields are read by the renderer in `@luckystack/docs-ui` only — the framework runtime never inspects them. They are a docs-presentation concern, not a routing concern.
3
+ Optional metadata lets projects annotate documentation without coupling every producer to a fixed UI release. Renderers should ignore unknown fields rather than failing when a newer producer adds them. Producers should not assume every consumer renders an optional field. The generated JSON and producer/renderer source are the authoritative schema; do not duplicate its field inventory here.
4
4
 
5
- `apiDocs.generated.json` is produced by `@luckystack/devkit`'s type-map emitter. The default template in `@luckystack/docs-ui` consumes a small, stable set of core fields and a handful of optional extension fields. Unknown fields pass through cleanly — the renderer ignores anything it doesn't recognize, so adding new metadata is forward-compatible.
6
-
7
- This document covers:
8
-
9
- - The top-level JSON shape (including the legacy fallback).
10
- - Core fields the renderer reads at the top level of each endpoint entry.
11
- - Optional extension fields rendered when present (`stream`, `owner`, `tags`, `deprecated`).
12
- - How to add your own metadata via the devkit emitter hooks.
13
-
14
- For the rendering pipeline that consumes these fields see `./html-generation.md`. For the route that serves the JSON see `./mounting.md`.
15
-
16
- ## Top-level JSON shape
17
-
18
- The current artifact 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
-
20
- ```jsonc
21
- {
22
- "apis": {
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"] }
36
- }
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
- ]
53
- }
54
- }
55
- ```
56
-
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.
58
-
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.
60
-
61
- ## Core fields (always read by the renderer)
62
-
63
- These are the fields used to build the always-visible row + the default expanded panel:
64
-
65
- | Field | Type | Default in renderer | Purpose |
66
- | --- | --- | --- | --- |
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. |
72
-
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.
74
-
75
- Unknown core fields fall through to their defaults silently; emit a missing `method`, for example, and the row shows `POST`.
76
-
77
- ## Optional extension fields
78
-
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.
80
-
81
- ### `stream` (top-level)
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.
84
- - Type: `string` (already-stringified TS shape, same convention as `input` / `output`).
85
- - Renders as: a `stream` detail section with the value inside a `<pre>` block.
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.
87
- - Example payload field:
88
-
89
- ```jsonc
90
- {
91
- "stream": "{ delta: string } | { done: true }"
92
- }
93
- ```
94
-
95
- ### `meta.owner`
96
-
97
- - Type: `string`.
98
- - Renders as: an `owner` detail section with the raw string.
99
- - Use for triage-friendly attribution — team name, Slack channel, GitHub handle, on-call rotation, etc. The value is escaped before injection so it is safe to include arbitrary text.
100
- - Example:
101
-
102
- ```jsonc
103
- { "owner": "@platform-team (#help-platform)" }
104
- ```
105
-
106
- ### `meta.tags`
107
-
108
- - Type: `string[]` (or array of values coerced via `String(...)`).
109
- - Renders as: inline badges in a `tags` detail section, space-separated.
110
- - Use for cross-cutting categorization the page/name hierarchy can't express — `"v2-migration"`, `"internal"`, `"experimental"`, etc.
111
- - Example:
112
-
113
- ```jsonc
114
- { "tags": ["experimental", "internal"] }
115
- ```
116
-
117
- Note: the renderer treats `tags` as an `Array.isArray(value)` check; arbitrary iterables are not supported. Emit a plain array.
118
-
119
- ### `meta.deprecated`
120
-
121
- - Type: `string | true` (anything truthy; `String(value)` is used for display).
122
- - Renders as: a `deprecated` detail section with red text (`color: var(--delete)`).
123
- - Use for a removal notice or migration pointer. The truthiness check means `"true"`, `"yes"`, `"removing v2026.05"`, etc. all render — but the renderer always shows the string value, so prefer a human-readable migration note over a bare boolean.
124
- - Example:
125
-
126
- ```jsonc
127
- {
128
- "deprecated": "Removed in v2026.05 — use users/getProfile/v2 instead."
129
- }
130
- ```
131
-
132
- ## Forward compatibility
133
-
134
- - Adding a new field to a `meta` object is non-breaking. The renderer ignores it.
135
- - Removing one of the four extension fields above does **not** break the renderer either — each is conditional on truthiness or array length.
136
- - The renderer does no schema validation. Malformed top-level JSON (e.g. wrong types at the page/name/version levels) shows the page's empty state with a hint to regenerate.
137
-
138
- If you need extra fields on the page, you have two options:
139
-
140
- 1. Extend the devkit emitter to include the field, then customize the rendering side via a `DocsTemplateBuilder` (see `./theming.md`) or, for trivial additions, contribute the field to this package.
141
- 2. Keep your metadata out-of-band (a sibling JSON file) and consume it from a custom template. The default renderer will not surface it, but a `DocsTemplateBuilder` can fetch both files.
142
-
143
- ## Producer contract — how fields are populated
144
-
145
- `apiDocs.generated.json` is written by `@luckystack/devkit`'s type-map emitter on each dev-server reload and on every `npm run generateArtifacts`. Path resolution goes through `ProjectConfig.paths.generatedApiDocs` (consumed by `getGeneratedApiDocsPath()` in `@luckystack/core`).
146
-
147
- Emission steps the devkit performs per endpoint:
148
-
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`).
150
- 2. Stringify the types into source-faithful strings — no re-stringification, no JSON.stringify of types.
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.
152
- 4. Group by `page` (one flat array of entries per page, each entry carrying its own `name` / `version` / `path`) and write the JSON.
153
-
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.
155
-
156
- ## Validation behavior
157
-
158
- - No schema validation. The renderer trusts the JSON shape.
159
- - A missing artifact (`apiDocs.generated.json` not on disk) yields a `404 JSON` response from the `mountDocsUi` handler with `expectedAt` + `hint` — surfaced as an inline error message in the page.
160
- - An empty / invalid `apis` map yields the page's empty state: `"No API docs available. Run npm run generateArtifacts to generate them."`.
161
- - Per-endpoint malformations (e.g. `auth` is a string instead of an object) fall back to defaults silently. Bad data does not crash the page; it just shows degraded content.
162
-
163
- ## Related
164
-
165
- - Rendering pipeline that consumes these fields: `./html-generation.md`
166
- - Mount + route: `./mounting.md`
167
- - Branding / custom templates: `./theming.md`
168
- - AI summary + function index: `../CLAUDE.md`
169
- - Producer: `packages/devkit/src/typeMap/emitter.ts` and `packages/devkit/src/typeMap/emitterArtifacts.ts`
5
+ Treat documentation metadata as publishable information. Ownership labels, tags, stream hints and deprecation messages can disclose private design details if the docs are exposed outside the team. Review the access boundary in `mounting.md` before enabling production serving or a custom renderer.