@luckystack/docs-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +55 -0
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +451 -0
- package/dist/index.js.map +1 -0
- package/docs/extension-fields.md +150 -0
- package/docs/html-generation.md +190 -0
- package/docs/mounting.md +221 -0
- package/docs/theming.md +172 -0
- package/package.json +55 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# HTML generation
|
|
2
|
+
|
|
3
|
+
> Dev-only. The renderer produces a self-contained HTML document with inline CSS and inline JavaScript. There is no build step, no React, and no external runtime dependency — the page works in any modern browser as soon as it can fetch `apiDocs.generated.json`.
|
|
4
|
+
|
|
5
|
+
This document covers the rendering half of `@luckystack/docs-ui`:
|
|
6
|
+
|
|
7
|
+
- `renderDocsHtml(jsonPath, pageTitle, options?)` — the default HTML builder.
|
|
8
|
+
- `RenderDocsHtmlOptions` — the option shape.
|
|
9
|
+
- `DocsTemplateBuilder` — the hook for replacing the renderer entirely.
|
|
10
|
+
- The embedded client pipeline: fetch, group, render, filter, "try it out".
|
|
11
|
+
|
|
12
|
+
For the mount/route side see `./mounting.md`. For branding tokens see `./theming.md`. For the JSON fields read by the renderer see `./extension-fields.md`.
|
|
13
|
+
|
|
14
|
+
## Function signature
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
export const renderDocsHtml = (
|
|
18
|
+
jsonPath: string,
|
|
19
|
+
pageTitle: string,
|
|
20
|
+
options?: RenderDocsHtmlOptions,
|
|
21
|
+
): string;
|
|
22
|
+
|
|
23
|
+
export interface RenderDocsHtmlOptions {
|
|
24
|
+
branding?: {
|
|
25
|
+
logoUrl?: string;
|
|
26
|
+
brandColor?: string;
|
|
27
|
+
fontFamily?: string;
|
|
28
|
+
};
|
|
29
|
+
enableTryItOut?: boolean;
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Return value: a complete `<!DOCTYPE html>...</html>` document as a single UTF-8 string. The caller is responsible for setting the `Content-Type: text/html; charset=utf-8` header (the `mountDocsUi` handler already does this).
|
|
34
|
+
|
|
35
|
+
### Arguments
|
|
36
|
+
|
|
37
|
+
| Arg | Type | Required | Purpose |
|
|
38
|
+
| --- | --- | --- | --- |
|
|
39
|
+
| `jsonPath` | `string` | Yes | Path the embedded script fetches with `fetch(jsonPath, { credentials: 'include' })`. Typically the `${routePath}/api.json` sibling produced by `mountDocsUi`, but any URL that returns the expected JSON shape works. |
|
|
40
|
+
| `pageTitle` | `string` | Yes | Used for `<title>` and `<h1>`. Passed through `escapeHtml` before interpolation. |
|
|
41
|
+
| `options.branding` | `DocsBranding` | No | Logo, accent color, font family. See `./theming.md`. |
|
|
42
|
+
| `options.enableTryItOut` | `boolean` | No (default `false`) | When `true`, each endpoint detail panel gets a textarea + Send button that posts to the live server. |
|
|
43
|
+
|
|
44
|
+
## Document structure
|
|
45
|
+
|
|
46
|
+
The produced HTML is one document with three logical sections:
|
|
47
|
+
|
|
48
|
+
1. **`<head>`** — meta tags, `<title>`, and a single inline `<style>` block.
|
|
49
|
+
2. **`<body>`** — a `.layout` wrapper containing the brand row, the filter bar, a summary pill row, and an empty `<div id="content">` filled in by the script.
|
|
50
|
+
3. **Inline `<script>`** — defines render helpers, fetches the JSON, wires up the filter input, and (optionally) the "try it out" runner.
|
|
51
|
+
|
|
52
|
+
There is no external `<link>`, `<script src="...">`, or web-font import. The page renders correctly when opened with a local file URL too, provided the JSON endpoint is reachable.
|
|
53
|
+
|
|
54
|
+
### CSS variables in `:root`
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
--bg, --container, --container-hover, --border
|
|
58
|
+
--title, --common, --muted
|
|
59
|
+
--accent (from branding.brandColor; default #58a6ff)
|
|
60
|
+
--font-family (from branding.fontFamily; default system stack)
|
|
61
|
+
--get (#3fb950)
|
|
62
|
+
--post (= --accent)
|
|
63
|
+
--put (#d29922)
|
|
64
|
+
--delete (#f85149)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A `@media (prefers-color-scheme: light)` block redefines `--bg`, `--container`, `--container-hover`, `--border`, `--title`, `--common`, `--muted` to a light palette. The accent and method colors do not switch — they are chosen to be legible on both backgrounds.
|
|
68
|
+
|
|
69
|
+
### Layout selectors
|
|
70
|
+
|
|
71
|
+
| Selector | Purpose |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `.layout` | Outer max-1200px container with vertical padding. |
|
|
74
|
+
| `.brand-row` | Holds the optional `<img>` logo and the `<h1>` page title. |
|
|
75
|
+
| `.summary` / `.summary-pill` | Top-of-page count pills ("`<visible>` of `<total>` endpoints", "`<pages>` pages"). |
|
|
76
|
+
| `.filter-bar input[type="search"]` | The single search input. Autofocus on load. |
|
|
77
|
+
| `.group` / `.group-header` | One block per `page` key in the JSON. |
|
|
78
|
+
| `.endpoint` / `.endpoint-summary` / `.endpoint-detail` | One row per endpoint. The detail panel is hidden until the row is clicked. |
|
|
79
|
+
| `.method.GET / .POST / .PUT / .DELETE` | Method pill, color-coded against the CSS variables above. |
|
|
80
|
+
| `.auth-tag` | Inline badges for each auth rule. |
|
|
81
|
+
| `.try-it-out` | Optional runner panel (only emitted when `enableTryItOut` is true). |
|
|
82
|
+
|
|
83
|
+
The styles are intentionally close to the framework's Tailwind tokens (background, container, muted, common) so the page looks at home in a LuckyStack project without configuration.
|
|
84
|
+
|
|
85
|
+
## Client-side rendering pipeline
|
|
86
|
+
|
|
87
|
+
The embedded `<script>` runs the following sequence after the HTML parses:
|
|
88
|
+
|
|
89
|
+
1. **`fetch(JSON_PATH, { credentials: 'include' })`** — pulls the JSON from `${routePath}/api.json` (or the explicit URL passed to `renderDocsHtml`). Credentials are included so a session cookie reaches the JSON endpoint when one is required.
|
|
90
|
+
2. On a successful response, the parsed payload is cached on `cachedData` and passed to `render(data, '')`.
|
|
91
|
+
3. On error (non-OK status or network failure) the page replaces `<div id="content">` with an inline error message — no console-only logging — so the user sees the failure without opening devtools.
|
|
92
|
+
4. The `<input type="search" id="filter">` registers an `input` listener that re-invokes `render(cachedData, filterValue)` on every keystroke. The fetch is not repeated.
|
|
93
|
+
|
|
94
|
+
### `render(data, filter)`
|
|
95
|
+
|
|
96
|
+
- Accepts either `{ apis: { ... } }` (current shape) or a bare `{ [page]: { ... } }` (legacy shape) — the renderer normalizes via `data && data.apis ? data.apis : data`.
|
|
97
|
+
- Iterates pages → names → versions, increments `total`, applies `passesFilter`, and accumulates HTML rows.
|
|
98
|
+
- Pages that have zero matching rows are omitted entirely (no empty `.group` boxes).
|
|
99
|
+
- Writes the `summary-pill` counts (`visible of total`, `pages count`) and the joined `.group` HTML into `<div id="content">`.
|
|
100
|
+
- After write, binds a `click` listener on every `.endpoint` to toggle the `.open` class and persist the state in `stateByKey` (keyed by `page + '/' + name + '@' + version`). The state is in-memory only; a hard reload resets all panels to closed.
|
|
101
|
+
|
|
102
|
+
### `renderEndpoint(page, name, version, meta)`
|
|
103
|
+
|
|
104
|
+
Each row pulls these fields from `meta`:
|
|
105
|
+
|
|
106
|
+
- `method` — uppercased and used as both the class and the label of the pill. Falls back to `POST` when undefined.
|
|
107
|
+
- `rateLimit` — `false` ⇒ "no rate limit"; `undefined` ⇒ "default rate"; number ⇒ "`<n>`/min".
|
|
108
|
+
- `auth` — passed to `renderAuth`. `auth.login` becomes `"login required"`. Each `auth.additional[]` entry contributes a `"<key>"` or `"<key>=<value>"` badge (value is `JSON.stringify`'d). Empty auth renders a `"public"` badge.
|
|
109
|
+
- `input`, `output` — already-stringified shapes from the type-map emitter. Rendered inside `<pre>` blocks. The fallbacks are `'{}'` and `'unknown'` respectively.
|
|
110
|
+
- `stream`, `owner`, `tags`, `deprecated` — optional extension fields, rendered conditionally. See `./extension-fields.md`.
|
|
111
|
+
|
|
112
|
+
The displayed endpoint path is `/api/<page>/<name>/<version>`; the path used by the runner is `<page>/<name>/<version>` posted to `/<route>?stream=false` (no leading `/api/`).
|
|
113
|
+
|
|
114
|
+
### `passesFilter(filter, page, name)`
|
|
115
|
+
|
|
116
|
+
Lowercase substring match against `page + '/' + name`. The version is not part of the haystack — filtering by version is intentional only via the page/route name.
|
|
117
|
+
|
|
118
|
+
### `escapeHtml` — two copies
|
|
119
|
+
|
|
120
|
+
The renderer ships two `escapeHtml` helpers that look identical and **must stay identical**:
|
|
121
|
+
|
|
122
|
+
1. The TypeScript helper at the bottom of `src/docsHtml.ts` is used to escape `pageTitle`, `jsonPath`, and `branding.logoUrl` before they are interpolated into the template literal at build time of the response.
|
|
123
|
+
2. The JS helper inside the inline `<script>` is used by the client to escape every value pulled from `apiDocs.generated.json` before it is injected into `innerHTML`.
|
|
124
|
+
|
|
125
|
+
Both map `&`, `<`, `>`, `"`, `'` to their HTML entity equivalents. The renderer never inserts unescaped user-supplied strings.
|
|
126
|
+
|
|
127
|
+
## "Try it out" runner
|
|
128
|
+
|
|
129
|
+
Enabled by `options.enableTryItOut === true`. When on:
|
|
130
|
+
|
|
131
|
+
- `ENABLE_TRY_IT_OUT` is `true` inside the inline script.
|
|
132
|
+
- `renderTryItOut(route, version)` appends a `<div class="try-it-out">` containing a `<textarea>` (default content `{}`), a `Send` button, and an empty `<pre class="result">`.
|
|
133
|
+
- The Send click invokes `runEndpoint(button, route, version, dataField)`.
|
|
134
|
+
|
|
135
|
+
`runEndpoint` performs:
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
fetch('/' + route + '?stream=false', {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: { 'Content-Type': 'application/json' },
|
|
141
|
+
credentials: 'include',
|
|
142
|
+
body: JSON.stringify(parsed),
|
|
143
|
+
})
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Behavior:
|
|
147
|
+
|
|
148
|
+
- `dataField.value.trim()` empty ⇒ posts `{}`. Otherwise the textarea value is parsed with `JSON.parse`. A parse error is shown in the result pane and no request is sent.
|
|
149
|
+
- The button is disabled during flight and re-enabled in `finally`.
|
|
150
|
+
- The response body is parsed as JSON when possible (pretty-printed via `JSON.stringify(..., null, 2)`) and otherwise displayed as raw text — this keeps non-JSON error pages readable.
|
|
151
|
+
- `?stream=false` instructs the HTTP API surface to return the full final payload instead of a streamed chunked response, which is what the textarea-and-pre UI is built for.
|
|
152
|
+
- Authentication is implicit via the browser session cookie (`credentials: 'include'`). The runner does not prompt for a token.
|
|
153
|
+
|
|
154
|
+
Disable the runner (`enableTryItOut: false`) when you ship to environments where developers don't have an authenticated session or you don't want side-effecting POSTs from the docs page.
|
|
155
|
+
|
|
156
|
+
## `DocsTemplateBuilder` — full replacement
|
|
157
|
+
|
|
158
|
+
When `mountDocsUi` is called with `template: DocsTemplateBuilder`, the default `renderDocsHtml` pipeline is bypassed entirely. The builder is invoked with:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
type DocsTemplateBuilder = (input: {
|
|
162
|
+
jsonPath: string;
|
|
163
|
+
pageTitle: string;
|
|
164
|
+
branding: DocsBranding;
|
|
165
|
+
}) => string;
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Contract for custom builders:
|
|
169
|
+
|
|
170
|
+
- Must return a full HTML document. The `mountDocsUi` handler streams the string as-is into `res.end(...)` with `Content-Type: text/html; charset=utf-8`.
|
|
171
|
+
- Should `fetch(jsonPath)` itself (or use `<script>` to do so). The handler does not pre-fetch the JSON.
|
|
172
|
+
- Should HTML-escape any inputs that end up in markup. The builder receives them as raw strings — `mountDocsUi` does not pre-escape.
|
|
173
|
+
- May ignore `branding` if the layout has its own theming primitives.
|
|
174
|
+
- May call `renderDocsHtml` from inside the builder if you only want to wrap the default rendering with extra chrome — but in that case you can usually use `branding` instead.
|
|
175
|
+
|
|
176
|
+
Use a template builder for full layout replacements (sidebar navigation, tabs, marketing-page chrome, dark/light toggle beyond OS preference). Use `branding` for cosmetic tweaks. See `./theming.md` for the trade-off.
|
|
177
|
+
|
|
178
|
+
## Forward compatibility
|
|
179
|
+
|
|
180
|
+
- Unknown `meta` fields on endpoints are ignored by the renderer.
|
|
181
|
+
- The renderer never schema-validates the JSON; malformed input falls back to the empty state with a `Run npm run generateArtifacts` hint.
|
|
182
|
+
- The two-shape acceptance (`{ apis }` vs bare map) is intentional to preserve compatibility with older artifact generators.
|
|
183
|
+
|
|
184
|
+
## Related
|
|
185
|
+
|
|
186
|
+
- Mounting + route handler: `./mounting.md`
|
|
187
|
+
- Branding tokens + template trade-off: `./theming.md`
|
|
188
|
+
- Extension fields rendered by the default template: `./extension-fields.md`
|
|
189
|
+
- AI summary + function index: `../CLAUDE.md`
|
|
190
|
+
- Source: `../src/docsHtml.ts`
|
package/docs/mounting.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Mounting the docs UI
|
|
2
|
+
|
|
3
|
+
> Dev-only. The default route returns `404` in production unless `enabledInProd: true` is explicitly set. Never mount this on a public surface where API discoverability is sensitive.
|
|
4
|
+
|
|
5
|
+
`@luckystack/docs-ui` exposes a single mount function that builds a `customRoutes` handler. The handler is composable with `createLuckyStackServer` / `bootstrapLuckyStack` and does not take ownership of the request pipeline — it claims the configured docs route and falls through for everything else.
|
|
6
|
+
|
|
7
|
+
This document is the deep reference for:
|
|
8
|
+
|
|
9
|
+
- `mountDocsUi(options?)` — the factory that builds the handler.
|
|
10
|
+
- `DocsRouteHandler` — the handler contract.
|
|
11
|
+
- `MountDocsUiOptions` — the full option surface.
|
|
12
|
+
|
|
13
|
+
For the HTML rendering side of the pipeline see `./html-generation.md`. For optional branding/template overrides see `./theming.md`. For the JSON fields the renderer consumes see `./extension-fields.md`.
|
|
14
|
+
|
|
15
|
+
## Function signatures
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
export const mountDocsUi = (options?: MountDocsUiOptions): DocsRouteHandler;
|
|
19
|
+
|
|
20
|
+
export type DocsRouteHandler = (
|
|
21
|
+
req: IncomingMessage,
|
|
22
|
+
res: ServerResponse,
|
|
23
|
+
) => Promise<boolean>;
|
|
24
|
+
|
|
25
|
+
export interface MountDocsUiOptions {
|
|
26
|
+
routePath?: string;
|
|
27
|
+
pageTitle?: string;
|
|
28
|
+
enabledInProd?: boolean;
|
|
29
|
+
apiDocsPath?: string;
|
|
30
|
+
branding?: DocsBranding;
|
|
31
|
+
template?: DocsTemplateBuilder;
|
|
32
|
+
enableTryItOut?: boolean;
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## `MountDocsUiOptions` argument breakdown
|
|
37
|
+
|
|
38
|
+
| Option | Type | Default | Purpose |
|
|
39
|
+
| --- | --- | --- | --- |
|
|
40
|
+
| `routePath` | `string` | `/_docs` | URL path the HTML page is served from. The JSON sibling is automatically derived as `${routePath}/api.json`. Both paths are claimed by the handler. |
|
|
41
|
+
| `pageTitle` | `string` | `LuckyStack — API docs` | Used for the `<title>` element and the `<h1>` in the header. Pass your project name explicitly — there is no implicit lookup from `ProjectConfig` because consumer config shapes vary too widely to introspect. |
|
|
42
|
+
| `enabledInProd` | `boolean` | `false` | Set to `true` to render the docs in production (`NODE_ENV === 'production'`). Use only for an internal developer-portal deployment with its own auth layer in front. |
|
|
43
|
+
| `apiDocsPath` | `string` | `getGeneratedApiDocsPath()` | Absolute filesystem path to `apiDocs.generated.json`. Default reads from `ProjectConfig.paths.generatedApiDocs` via `@luckystack/core`. Override only when running a non-standard artifact layout. |
|
|
44
|
+
| `branding` | `DocsBranding` | `{}` | Optional logo / accent color / font family. Applied by the default template only. See `./theming.md`. |
|
|
45
|
+
| `template` | `DocsTemplateBuilder` | `undefined` | Custom HTML builder. When provided, the default `renderDocsHtml` is bypassed entirely. See `./html-generation.md` and `./theming.md`. |
|
|
46
|
+
| `enableTryItOut` | `boolean` | `false` | Renders an inline request runner under each endpoint. Off by default — the runner needs a logged-in browser session because it hits the live server with `credentials: 'include'`. |
|
|
47
|
+
|
|
48
|
+
All options are individually optional; calling `mountDocsUi()` with no arguments is valid and produces the default behavior.
|
|
49
|
+
|
|
50
|
+
## `DocsRouteHandler` contract
|
|
51
|
+
|
|
52
|
+
The factory returns an async function with this exact shape:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
type DocsRouteHandler = (
|
|
56
|
+
req: IncomingMessage,
|
|
57
|
+
res: ServerResponse,
|
|
58
|
+
) => Promise<boolean>;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Return-value contract:
|
|
62
|
+
|
|
63
|
+
- `true` — the handler has fully processed the request and called `res.end(...)`. The caller (typically `@luckystack/server`'s `customRoutes` orchestration) must not invoke any further handler for this request.
|
|
64
|
+
- `false` — the handler did not match the URL. The request body is untouched and the response is unwritten. The caller is free to delegate to the next handler or fall through to the framework's default routing.
|
|
65
|
+
|
|
66
|
+
The handler is safe to compose: it inspects only `req.url` and `req.method` and never reads the body. It is always async because reading `apiDocs.generated.json` from disk happens via `fs.promises.readFile` wrapped in `tryCatch`.
|
|
67
|
+
|
|
68
|
+
## Compositing with `createLuckyStackServer`
|
|
69
|
+
|
|
70
|
+
Minimum integration — pass the handler as `customRoutes`:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
// luckystack/docs-ui/index.ts (overlay file)
|
|
74
|
+
import { mountDocsUi } from '@luckystack/docs-ui';
|
|
75
|
+
|
|
76
|
+
export const docsUiHandler = mountDocsUi({
|
|
77
|
+
pageTitle: 'My App — API docs',
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// server/server.ts
|
|
83
|
+
import { bootstrapLuckyStack } from '@luckystack/server';
|
|
84
|
+
import { docsUiHandler } from '../luckystack/docs-ui';
|
|
85
|
+
import { serveFile, serveFavicon } from './prod/serveFile';
|
|
86
|
+
|
|
87
|
+
await (await bootstrapLuckyStack({
|
|
88
|
+
serveFile,
|
|
89
|
+
serveFavicon,
|
|
90
|
+
customRoutes: docsUiHandler,
|
|
91
|
+
})).listen();
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Composing with another custom handler — chain by `await`-ing the docs handler first and only continuing when it returns `false`:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
98
|
+
import { mountDocsUi } from '@luckystack/docs-ui';
|
|
99
|
+
import { metricsHandler } from './metrics';
|
|
100
|
+
|
|
101
|
+
const docsHandler = mountDocsUi();
|
|
102
|
+
|
|
103
|
+
export const customRoutes = async (
|
|
104
|
+
req: IncomingMessage,
|
|
105
|
+
res: ServerResponse,
|
|
106
|
+
): Promise<boolean> => {
|
|
107
|
+
if (await docsHandler(req, res)) return true;
|
|
108
|
+
if (await metricsHandler(req, res)) return true;
|
|
109
|
+
return false;
|
|
110
|
+
};
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The order matters only when two handlers would claim the same path; `mountDocsUi` only ever claims `routePath` + `${routePath}/api.json`, so collisions are easy to avoid by picking a unique `routePath`.
|
|
114
|
+
|
|
115
|
+
## Behavior matrix
|
|
116
|
+
|
|
117
|
+
The handler's branches map directly to the request shape. The order is exactly as evaluated in `src/index.ts`:
|
|
118
|
+
|
|
119
|
+
| Step | Condition | Result |
|
|
120
|
+
| --- | --- | --- |
|
|
121
|
+
| 1 | `pathOnly !== routePath && pathOnly !== ${routePath}/api.json` | Returns `false` (pass-through). No response written. |
|
|
122
|
+
| 2 | `NODE_ENV === 'production'` and `!options.enabledInProd` | `404 Not Found` (`text/plain`). Returns `true`. |
|
|
123
|
+
| 3 | `req.method !== 'GET'` | `405 Method Not Allowed` (`text/plain`). Returns `true`. |
|
|
124
|
+
| 4 | `pathOnly === ${routePath}/api.json` and the file is readable | `200 OK` (`application/json`, `Cache-Control: no-store`) with the raw file contents. Returns `true`. |
|
|
125
|
+
| 5 | `pathOnly === ${routePath}/api.json` and the file is missing/unreadable | `404 Not Found` (`application/json`) with `{ error, expectedAt, hint }`. Returns `true`. |
|
|
126
|
+
| 6 | `pathOnly === routePath` and `options.template` is set | `200 OK` (`text/html; charset=utf-8`) with the template builder's output. Returns `true`. |
|
|
127
|
+
| 7 | `pathOnly === routePath` and no template | `200 OK` (`text/html; charset=utf-8`) with `renderDocsHtml(...)`. Returns `true`. |
|
|
128
|
+
|
|
129
|
+
The order is critical: prod-gating runs before method-checking, so a `POST /_docs` in production still returns `404` (it's gated by env first), not `405`. The match itself ignores query strings (the path is split on `?` before comparing) and is strict on trailing slashes — `/_docs/` does NOT match the default route.
|
|
130
|
+
|
|
131
|
+
## Response shapes
|
|
132
|
+
|
|
133
|
+
### HTML page (`GET ${routePath}`)
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
HTTP/1.1 200 OK
|
|
137
|
+
Content-Type: text/html; charset=utf-8
|
|
138
|
+
Cache-Control: no-store
|
|
139
|
+
|
|
140
|
+
<!DOCTYPE html>
|
|
141
|
+
<html lang="en">
|
|
142
|
+
...self-contained docs page (see ./html-generation.md)...
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### JSON endpoint, success (`GET ${routePath}/api.json`)
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
HTTP/1.1 200 OK
|
|
149
|
+
Content-Type: application/json
|
|
150
|
+
Cache-Control: no-store
|
|
151
|
+
|
|
152
|
+
{ "apis": { "<page>": { "<name>": { "<version>": { ... } } } } }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The file is streamed as-is from disk; the handler does not parse, validate, or transform it. Forward-compatible extension fields (see `./extension-fields.md`) pass through unchanged.
|
|
156
|
+
|
|
157
|
+
### JSON endpoint, missing file
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
HTTP/1.1 404 Not Found
|
|
161
|
+
Content-Type: application/json
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
"error": "apiDocs.generated.json not found",
|
|
165
|
+
"expectedAt": "<resolved apiDocsPath>",
|
|
166
|
+
"hint": "Run `npm run generateArtifacts` to generate it."
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
This is intentionally a structured payload so the client-side renderer can surface a helpful empty state. `Cache-Control` is omitted on the error path — the request should be retried after running the artifact generator.
|
|
171
|
+
|
|
172
|
+
### Production lockdown (`enabledInProd: false`)
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
HTTP/1.1 404 Not Found
|
|
176
|
+
Content-Type: text/plain
|
|
177
|
+
|
|
178
|
+
Not Found
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The same plain `Not Found` is returned for both the HTML and the JSON sub-route, so probing tools cannot infer which routes the handler owns.
|
|
182
|
+
|
|
183
|
+
### Wrong method
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
HTTP/1.1 405 Method Not Allowed
|
|
187
|
+
Content-Type: text/plain
|
|
188
|
+
|
|
189
|
+
Method Not Allowed
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`POST`, `PUT`, `DELETE`, and other verbs all map to `405`. The `enableTryItOut` runner posts to `/api/<page>/<name>/<version>?stream=false` (the framework's HTTP API surface), not to the docs route — so `GET`-only is sufficient.
|
|
193
|
+
|
|
194
|
+
## `apiDocsPath` resolution
|
|
195
|
+
|
|
196
|
+
The default `apiDocsPath` is resolved lazily on every request via `getGeneratedApiDocsPath()` from `@luckystack/core`. That helper reads `ProjectConfig.paths.generatedApiDocs`, which is populated either via the framework defaults or via an overlay's `registerProjectConfig` call.
|
|
197
|
+
|
|
198
|
+
When you pass `apiDocsPath` explicitly:
|
|
199
|
+
|
|
200
|
+
- The value must be an absolute path. The handler does no resolution relative to `process.cwd()`.
|
|
201
|
+
- The file is read with `fs.promises.readFile(path, 'utf8')` on every request — there is no in-memory cache. The doc-set is dev-only and the artifact regenerates often; caching is intentionally avoided.
|
|
202
|
+
- Read errors are caught via `tryCatch`. The response is a structured `404` with `expectedAt` pointing at the resolved path so you can diff it against your actual layout.
|
|
203
|
+
|
|
204
|
+
## Edge cases
|
|
205
|
+
|
|
206
|
+
- **Missing `apiDocs.generated.json`** — Returns the structured `404` JSON payload above. Run `npm run generateArtifacts` to produce it.
|
|
207
|
+
- **Trailing slashes** — The handler does strict string equality on the resolved path. `/_docs/` is treated as a different route from `/_docs`. Configure `routePath` to include or omit the trailing slash consistently with the rest of your router.
|
|
208
|
+
- **Query strings** — The path is split on `?` before comparison, so `/_docs?theme=dark` matches. Query parameters are otherwise ignored by the handler.
|
|
209
|
+
- **Custom `routePath`** — Any string is accepted; the `${routePath}/api.json` sibling is derived by simple concatenation. Avoid trailing slashes in `routePath` (`/_docs/` would yield `/_docs//api.json`).
|
|
210
|
+
- **`HEAD` requests** — Not special-cased. They fall into the `405` branch. If you need `HEAD` support, wrap the handler.
|
|
211
|
+
- **Concurrent requests** — Each request reads the JSON file independently. There is no shared mutable state inside the handler.
|
|
212
|
+
- **`req.url` missing** — The handler treats an undefined `url` as an empty string, which never matches `routePath`. It returns `false` (pass-through) cleanly.
|
|
213
|
+
|
|
214
|
+
## Related
|
|
215
|
+
|
|
216
|
+
- HTML rendering pipeline: `./html-generation.md`
|
|
217
|
+
- Branding and full template replacement: `./theming.md`
|
|
218
|
+
- Extension fields rendered from `apiDocs.generated.json`: `./extension-fields.md`
|
|
219
|
+
- AI summary + function index: `../CLAUDE.md`
|
|
220
|
+
- Consumer quickstart: `../README.md`
|
|
221
|
+
- Source: `../src/index.ts`
|
package/docs/theming.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Theming the docs UI
|
|
2
|
+
|
|
3
|
+
> Dev-only. The default docs page is a single self-contained HTML document with inline CSS. Theming is intentionally minimal — three branding tokens for cosmetic tweaks, plus a full template-builder hook when you need a different layout.
|
|
4
|
+
|
|
5
|
+
This document covers:
|
|
6
|
+
|
|
7
|
+
- `DocsBranding` — the three branding tokens and how they map to CSS variables.
|
|
8
|
+
- The default palette + dark/light auto-switch.
|
|
9
|
+
- `DocsTemplateBuilder` — the escape hatch for full layout replacement.
|
|
10
|
+
- When to choose `branding` vs `template`.
|
|
11
|
+
|
|
12
|
+
For the renderer that consumes these tokens see `./html-generation.md`. For the route handler that wires them in see `./mounting.md`.
|
|
13
|
+
|
|
14
|
+
## `DocsBranding`
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
export interface DocsBranding {
|
|
18
|
+
logoUrl?: string;
|
|
19
|
+
brandColor?: string;
|
|
20
|
+
fontFamily?: string;
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
All three keys are optional. Pass through `mountDocsUi({ branding: { ... } })` or directly to `renderDocsHtml(jsonPath, pageTitle, { branding })`.
|
|
25
|
+
|
|
26
|
+
| Token | Type | Default | Mapping |
|
|
27
|
+
| --- | --- | --- | --- |
|
|
28
|
+
| `logoUrl` | `string` | _(no logo)_ | Injected as `<img src="..." alt="logo" style="height:32px;width:auto;margin-right:12px;" />` in the brand row, to the left of the `<h1>`. The URL is HTML-escaped before injection. |
|
|
29
|
+
| `brandColor` | `string` (CSS color) | `#58a6ff` | Sets the CSS variable `--accent` and (because POST is the canonical authenticated verb in LuckyStack) also `--post`, the POST method pill color. |
|
|
30
|
+
| `fontFamily` | `string` (CSS font-family) | `system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif` | Sets `--font-family`. Applied to `<body>` and inherited by inputs (the search bar uses `font-family: inherit`). |
|
|
31
|
+
|
|
32
|
+
The renderer applies these tokens in the inline `<style>` block at the top of the document. There is no runtime style swap; changing branding requires a fresh HTML response.
|
|
33
|
+
|
|
34
|
+
### Logo handling
|
|
35
|
+
|
|
36
|
+
- The renderer accepts PNG, SVG, and `data:` URLs equally.
|
|
37
|
+
- The string is escaped via the same `escapeHtml` helper used for `pageTitle` and `jsonPath`. Embedding hostile content in the `logoUrl` cannot break out of the `src` attribute.
|
|
38
|
+
- The img tag is fixed at `height: 32px; width: auto;`. If you need a different size, embed an SVG with a `viewBox` so it scales correctly, or use a `template` builder.
|
|
39
|
+
- When `logoUrl` is omitted, the brand row contains only the `<h1>`. There is no placeholder block.
|
|
40
|
+
|
|
41
|
+
### Brand color cascade
|
|
42
|
+
|
|
43
|
+
The branding color is wired into two CSS variables:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
--accent: <brandColor>
|
|
47
|
+
--post: <brandColor> // (POST method pill)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
All other method colors are fixed:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
--get: #3fb950 (green)
|
|
54
|
+
--put: #d29922 (amber, with dark text)
|
|
55
|
+
--delete: #f85149 (red)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This intentional split keeps GET/PUT/DELETE recognizable across themes while letting the POST pill match the brand. If you need per-method theming, use a `template` builder.
|
|
59
|
+
|
|
60
|
+
### Font handling
|
|
61
|
+
|
|
62
|
+
`fontFamily` flows into `--font-family` and is consumed by `<body>` via `font-family: var(--font-family);`. The `<input type="search">` uses `font-family: inherit` so the search bar adopts the brand font. Code blocks (`pre`, `.endpoint`, `.try-it-out textarea`) use a fixed monospace stack (`ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace`) and are not affected by `fontFamily`.
|
|
63
|
+
|
|
64
|
+
Quoting and escaping: `fontFamily` is interpolated **as-is** into the CSS string. Provide a valid CSS font-family value, including quotes around multi-word family names when needed (e.g. `'Inter', system-ui, sans-serif`).
|
|
65
|
+
|
|
66
|
+
## Dark / light auto-switch
|
|
67
|
+
|
|
68
|
+
The page respects the OS preference via `prefers-color-scheme`. There is no in-page theme toggle.
|
|
69
|
+
|
|
70
|
+
Dark palette (default `:root` values):
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
--bg: #0e1116 --container: #161b22 --container-hover: #1c232b
|
|
74
|
+
--border: #2a313c --title: #e6edf3 --common: #c9d1d9
|
|
75
|
+
--muted: #8b949e
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Light palette (overridden inside `@media (prefers-color-scheme: light)`):
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
--bg: #ffffff --container: #f6f8fa --container-hover: #eaeef2
|
|
82
|
+
--border: #d0d7de --title: #1f2328 --common: #1f2328
|
|
83
|
+
--muted: #59636e
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Variables that are **not** overridden in the light block — `--accent`, `--font-family`, `--get`, `--post`, `--put`, `--delete` — are chosen to be legible against both surfaces. The PUT pill flips its text color to dark via `.method.PUT { color: #1f2328; }` to remain legible on amber.
|
|
87
|
+
|
|
88
|
+
## When to use `branding` vs `template`
|
|
89
|
+
|
|
90
|
+
Use `branding` (default template) when:
|
|
91
|
+
|
|
92
|
+
- You only need to swap the logo, the accent color, and/or the font.
|
|
93
|
+
- You're happy with the single-column endpoints-grouped-by-page layout.
|
|
94
|
+
- You want light/dark auto-switching for free.
|
|
95
|
+
- You don't need to change the inline runner, the filter UI, or the JSON-fetch flow.
|
|
96
|
+
|
|
97
|
+
Use `template: DocsTemplateBuilder` when:
|
|
98
|
+
|
|
99
|
+
- You need a different layout — sidebar navigation, tabs, a marketing-page wrapper, an embedded auth-token field, a two-pane editor/inspector view.
|
|
100
|
+
- You want a per-method palette beyond the POST = `brandColor` cascade.
|
|
101
|
+
- You need to integrate with a corporate design system (component library, web fonts loaded from a CDN, etc.).
|
|
102
|
+
- You want an in-page light/dark toggle independent of the OS preference.
|
|
103
|
+
|
|
104
|
+
## Example — corporate brand palette
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { mountDocsUi } from '@luckystack/docs-ui';
|
|
108
|
+
|
|
109
|
+
export const docsUiHandler = mountDocsUi({
|
|
110
|
+
pageTitle: 'Acme — API docs',
|
|
111
|
+
branding: {
|
|
112
|
+
logoUrl: 'https://cdn.acme.example/logo.svg',
|
|
113
|
+
brandColor: '#7c3aed',
|
|
114
|
+
fontFamily: `'Inter', system-ui, -apple-system, sans-serif`,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This renders the default layout with the Acme logo, purple accent (also applied to the POST pill), and Inter as the body font. GET/PUT/DELETE pills remain green/amber/red.
|
|
120
|
+
|
|
121
|
+
## Example — full template replacement
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { mountDocsUi, type DocsTemplateBuilder } from '@luckystack/docs-ui';
|
|
125
|
+
|
|
126
|
+
const sidebarTemplate: DocsTemplateBuilder = ({ jsonPath, pageTitle, branding }) => `
|
|
127
|
+
<!DOCTYPE html>
|
|
128
|
+
<html lang="en">
|
|
129
|
+
<head>
|
|
130
|
+
<meta charset="utf-8" />
|
|
131
|
+
<title>${pageTitle}</title>
|
|
132
|
+
<link rel="stylesheet" href="https://cdn.example.com/corporate-tokens.css" />
|
|
133
|
+
</head>
|
|
134
|
+
<body>
|
|
135
|
+
<aside class="sidebar">${branding.logoUrl ? `<img src="${branding.logoUrl}" />` : ''}</aside>
|
|
136
|
+
<main id="content">Loading...</main>
|
|
137
|
+
<script>
|
|
138
|
+
fetch(${JSON.stringify(jsonPath)}, { credentials: 'include' })
|
|
139
|
+
.then(r => r.json())
|
|
140
|
+
.then(data => { /* render however you like */ });
|
|
141
|
+
</script>
|
|
142
|
+
</body>
|
|
143
|
+
</html>`;
|
|
144
|
+
|
|
145
|
+
export const docsUiHandler = mountDocsUi({
|
|
146
|
+
template: sidebarTemplate,
|
|
147
|
+
branding: { logoUrl: 'https://cdn.example.com/logo.svg' },
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Contract reminders for custom templates (see `./html-generation.md` for the full contract):
|
|
152
|
+
|
|
153
|
+
- Return a complete `<!DOCTYPE html>...</html>` document.
|
|
154
|
+
- HTML-escape any inputs that end up in markup — the builder receives raw strings.
|
|
155
|
+
- Fetch `jsonPath` yourself; the handler does not pre-fetch.
|
|
156
|
+
- You may receive a non-empty `branding` even when you don't use it — that's fine, ignore the fields you don't need.
|
|
157
|
+
|
|
158
|
+
## Limits
|
|
159
|
+
|
|
160
|
+
- No per-endpoint color override. The method pill colors are fixed CSS variables.
|
|
161
|
+
- No runtime theme switcher beyond OS preference in the default template. Implement one inside a custom template if needed.
|
|
162
|
+
- No web-font loading. The default template does not inject `<link rel="stylesheet">` tags — bring your own via a custom template.
|
|
163
|
+
- No localization. Strings like "Try it out (live request)", "No matches.", "Run `npm run generateArtifacts`" are hardcoded English. Replace via a custom template if you need translations.
|
|
164
|
+
- `brandColor` must be a valid single CSS color value (hex, named, `rgb()`, `hsl()`). Gradients are not supported.
|
|
165
|
+
|
|
166
|
+
## Related
|
|
167
|
+
|
|
168
|
+
- The renderer that consumes these tokens: `./html-generation.md`
|
|
169
|
+
- The mount handler that accepts them: `./mounting.md`
|
|
170
|
+
- The JSON fields the page renders: `./extension-fields.md`
|
|
171
|
+
- AI summary + function index: `../CLAUDE.md`
|
|
172
|
+
- Source: `../src/index.ts`, `../src/docsHtml.ts`
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@luckystack/docs-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"description": "Dev-only API docs UI for LuckyStack. Mounts a Swagger-style page at /_docs that reads `apiDocs.generated.json` and renders every API route, its inputs/outputs, auth, and rate-limit metadata.",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Mathijs van Melick <mathijsvanmelick3@gmail.com>",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/ItsLucky23/LuckyStack-v2.git",
|
|
15
|
+
"directory": "packages/docs-ui"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/ItsLucky23/LuckyStack-v2/tree/master/packages/docs-ui#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/ItsLucky23/LuckyStack-v2/issues"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"luckystack",
|
|
23
|
+
"docs",
|
|
24
|
+
"api-docs",
|
|
25
|
+
"swagger",
|
|
26
|
+
"framework"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20.0.0"
|
|
30
|
+
},
|
|
31
|
+
"main": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"import": "./dist/index.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"README.md",
|
|
42
|
+
"CLAUDE.md",
|
|
43
|
+
"docs",
|
|
44
|
+
"LICENSE",
|
|
45
|
+
"CHANGELOG.md"
|
|
46
|
+
],
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsup",
|
|
49
|
+
"build:watch": "tsup --watch",
|
|
50
|
+
"test": "vitest run"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@luckystack/core": "^0.1.0"
|
|
54
|
+
}
|
|
55
|
+
}
|