@luckystack/docs-ui 0.4.1 → 0.5.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.
@@ -1,194 +1,194 @@
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, syncs }` (current shape) or a bare `{ [page]: Entry[] }` (legacy shape) — the renderer normalizes via `data && data.apis ? data.apis : data` (and `data.syncs` ?? `{}`), then delegates to `buildGroups(apis, syncs, filter)`.
97
- - `buildGroups` walks each page's flat array of entries (and the parallel `syncs` map the same way), increments `total`, applies `passesFilter`, and accumulates HTML rows via `renderEndpoint` / `renderSyncEntry`.
98
- - 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
- - Open/closed state is toggled by a single delegated `click` listener on `#content` (registered once via `bindEndpointToggles`, not re-bound per render), persisting state in `stateByKey` (keyed by `page + '/' + name + '@' + version`). The state is in-memory only; a hard reload resets all panels to closed.
101
-
102
- ### `renderEndpoint(entry)`
103
-
104
- Takes a single entry object and pulls these fields directly off it:
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` — a top-level field on the entry, rendered when truthy and not `'never'`.
111
- - `meta.owner`, `meta.tags`, `meta.deprecated` — optional fields under the entry's `meta` object, rendered conditionally. See `./extension-fields.md`.
112
-
113
- The displayed endpoint path is `/api/<page>/<name>/<version>`; the path used by the runner is `<page>/<name>/<version>` posted to `/<route>?stream=false` (no leading `/api/`).
114
-
115
- ### `passesFilter(filter, page, name)`
116
-
117
- 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.
118
-
119
- ### `escapeHtml` — two copies
120
-
121
- The renderer ships two `escapeHtml` helpers that look identical and **must stay identical**:
122
-
123
- 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.
124
- 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`.
125
-
126
- Both map `&`, `<`, `>`, `"`, `'` to their HTML entity equivalents. The renderer never inserts unescaped user-supplied strings.
127
-
128
- ## "Try it out" runner
129
-
130
- Enabled by `options.enableTryItOut === true`. When on:
131
-
132
- - `ENABLE_TRY_IT_OUT` is `true` inside the inline script.
133
- - `renderTryItOut(route, method)` appends a `<div class="try-it-out">` containing a `<textarea>` (default content `{}`), a `Send` button, and an empty `<pre class="result">`.
134
- - The Send click invokes `runEndpoint(button, route, method, dataField)`.
135
-
136
- `runEndpoint` performs:
137
-
138
- ```js
139
- const httpMethod = (method || 'POST').toUpperCase();
140
- const hasBody = httpMethod !== 'GET' && httpMethod !== 'DELETE';
141
- fetch('/' + route + '?stream=false', {
142
- method: httpMethod,
143
- headers: hasBody ? { 'Content-Type': 'application/json' } : {},
144
- credentials: 'include',
145
- body: hasBody ? JSON.stringify(parsed) : undefined,
146
- })
147
- ```
148
-
149
- Behavior:
150
-
151
- - The endpoint's declared HTTP method is threaded through (the framework returns `405` on a method mismatch, so a hardcoded `POST` would break every documented GET/PUT/DELETE route). For `GET`/`DELETE` the JSON body and `Content-Type` header are omitted.
152
- - `dataField.value.trim()` empty ⇒ sends `{}` (for body-bearing methods). Otherwise the textarea value is parsed with `JSON.parse`. A parse error is shown in the result pane and no request is sent.
153
- - The button is disabled during flight and re-enabled in `finally`.
154
- - 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.
155
- - `?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.
156
- - Authentication is implicit via the browser session cookie (`credentials: 'include'`). The runner does not prompt for a token.
157
-
158
- 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.
159
-
160
- ## `DocsTemplateBuilder` — full replacement
161
-
162
- When `mountDocsUi` is called with `template: DocsTemplateBuilder`, the default `renderDocsHtml` pipeline is bypassed entirely. The builder is invoked with:
163
-
164
- ```ts
165
- type DocsTemplateBuilder = (input: {
166
- jsonPath: string;
167
- pageTitle: string;
168
- branding: DocsBranding;
169
- }) => string;
170
- ```
171
-
172
- Contract for custom builders:
173
-
174
- - 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`.
175
- - Should `fetch(jsonPath)` itself (or use `<script>` to do so). The handler does not pre-fetch the JSON.
176
- - Should HTML-escape any inputs that end up in markup. The builder receives them as raw strings — `mountDocsUi` does not pre-escape.
177
- - May ignore `branding` if the layout has its own theming primitives.
178
- - 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.
179
-
180
- 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.
181
-
182
- ## Forward compatibility
183
-
184
- - Unknown `meta` fields on endpoints are ignored by the renderer.
185
- - The renderer never schema-validates the JSON; malformed input falls back to the empty state with a `Run npm run generateArtifacts` hint.
186
- - The two-shape acceptance (`{ apis }` vs bare map) is intentional to preserve compatibility with older artifact generators.
187
-
188
- ## Related
189
-
190
- - Mounting + route handler: `./mounting.md`
191
- - Branding tokens + template trade-off: `./theming.md`
192
- - Extension fields rendered by the default template: `./extension-fields.md`
193
- - AI summary + function index: `../CLAUDE.md`
194
- - Source: `../src/docsHtml.ts`
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, syncs }` (current shape) or a bare `{ [page]: Entry[] }` (legacy shape) — the renderer normalizes via `data && data.apis ? data.apis : data` (and `data.syncs` ?? `{}`), then delegates to `buildGroups(apis, syncs, filter)`.
97
+ - `buildGroups` walks each page's flat array of entries (and the parallel `syncs` map the same way), increments `total`, applies `passesFilter`, and accumulates HTML rows via `renderEndpoint` / `renderSyncEntry`.
98
+ - 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
+ - Open/closed state is toggled by a single delegated `click` listener on `#content` (registered once via `bindEndpointToggles`, not re-bound per render), persisting state in `stateByKey` (keyed by `page + '/' + name + '@' + version`). The state is in-memory only; a hard reload resets all panels to closed.
101
+
102
+ ### `renderEndpoint(entry)`
103
+
104
+ Takes a single entry object and pulls these fields directly off it:
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` — a top-level field on the entry, rendered when truthy and not `'never'`.
111
+ - `meta.owner`, `meta.tags`, `meta.deprecated` — optional fields under the entry's `meta` object, rendered conditionally. See `./extension-fields.md`.
112
+
113
+ The displayed endpoint path is `/api/<page>/<name>/<version>`; the path used by the runner is `<page>/<name>/<version>` posted to `/<route>?stream=false` (no leading `/api/`).
114
+
115
+ ### `passesFilter(filter, page, name)`
116
+
117
+ 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.
118
+
119
+ ### `escapeHtml` — two copies
120
+
121
+ The renderer ships two `escapeHtml` helpers that look identical and **must stay identical**:
122
+
123
+ 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.
124
+ 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`.
125
+
126
+ Both map `&`, `<`, `>`, `"`, `'` to their HTML entity equivalents. The renderer never inserts unescaped user-supplied strings.
127
+
128
+ ## "Try it out" runner
129
+
130
+ Enabled by `options.enableTryItOut === true`. When on:
131
+
132
+ - `ENABLE_TRY_IT_OUT` is `true` inside the inline script.
133
+ - `renderTryItOut(route, method)` appends a `<div class="try-it-out">` containing a `<textarea>` (default content `{}`), a `Send` button, and an empty `<pre class="result">`.
134
+ - The Send click invokes `runEndpoint(button, route, method, dataField)`.
135
+
136
+ `runEndpoint` performs:
137
+
138
+ ```js
139
+ const httpMethod = (method || 'POST').toUpperCase();
140
+ const hasBody = httpMethod !== 'GET' && httpMethod !== 'DELETE';
141
+ fetch('/' + route + '?stream=false', {
142
+ method: httpMethod,
143
+ headers: hasBody ? { 'Content-Type': 'application/json' } : {},
144
+ credentials: 'include',
145
+ body: hasBody ? JSON.stringify(parsed) : undefined,
146
+ })
147
+ ```
148
+
149
+ Behavior:
150
+
151
+ - The endpoint's declared HTTP method is threaded through (the framework returns `405` on a method mismatch, so a hardcoded `POST` would break every documented GET/PUT/DELETE route). For `GET`/`DELETE` the JSON body and `Content-Type` header are omitted.
152
+ - `dataField.value.trim()` empty ⇒ sends `{}` (for body-bearing methods). Otherwise the textarea value is parsed with `JSON.parse`. A parse error is shown in the result pane and no request is sent.
153
+ - The button is disabled during flight and re-enabled in `finally`.
154
+ - 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.
155
+ - `?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.
156
+ - Authentication is implicit via the browser session cookie (`credentials: 'include'`). The runner does not prompt for a token.
157
+
158
+ 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.
159
+
160
+ ## `DocsTemplateBuilder` — full replacement
161
+
162
+ When `mountDocsUi` is called with `template: DocsTemplateBuilder`, the default `renderDocsHtml` pipeline is bypassed entirely. The builder is invoked with:
163
+
164
+ ```ts
165
+ type DocsTemplateBuilder = (input: {
166
+ jsonPath: string;
167
+ pageTitle: string;
168
+ branding: DocsBranding;
169
+ }) => string;
170
+ ```
171
+
172
+ Contract for custom builders:
173
+
174
+ - 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`.
175
+ - Should `fetch(jsonPath)` itself (or use `<script>` to do so). The handler does not pre-fetch the JSON.
176
+ - Should HTML-escape any inputs that end up in markup. The builder receives them as raw strings — `mountDocsUi` does not pre-escape.
177
+ - May ignore `branding` if the layout has its own theming primitives.
178
+ - 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.
179
+
180
+ 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.
181
+
182
+ ## Forward compatibility
183
+
184
+ - Unknown `meta` fields on endpoints are ignored by the renderer.
185
+ - The renderer never schema-validates the JSON; malformed input falls back to the empty state with a `Run npm run generateArtifacts` hint.
186
+ - The two-shape acceptance (`{ apis }` vs bare map) is intentional to preserve compatibility with older artifact generators.
187
+
188
+ ## Related
189
+
190
+ - Mounting + route handler: `./mounting.md`
191
+ - Branding tokens + template trade-off: `./theming.md`
192
+ - Extension fields rendered by the default template: `./extension-fields.md`
193
+ - AI summary + function index: `../CLAUDE.md`
194
+ - Source: `../src/docsHtml.ts`