@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.
- package/CLAUDE.md +55 -55
- package/README.md +90 -90
- package/dist/{chunk-XFLDJWH2.js → chunk-3Y7IA5DO.js} +1 -1
- package/dist/chunk-3Y7IA5DO.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/register.js +1 -1
- package/dist/register.js.map +1 -1
- package/docs/extension-fields.md +169 -169
- package/docs/html-generation.md +194 -194
- package/docs/mounting.md +226 -226
- package/docs/theming.md +172 -172
- package/package.json +3 -3
- package/dist/chunk-XFLDJWH2.js.map +0 -1
package/docs/theming.md
CHANGED
|
@@ -1,172 +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 `https:`, `http:`, and scheme-free relative URLs (e.g. `/logo.svg`). `data:` and `javascript:` URLs are **rejected** — the logo will be silently omitted — because SVG data-URIs can carry script.
|
|
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`
|
|
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 `https:`, `http:`, and scheme-free relative URLs (e.g. `/logo.svg`). `data:` and `javascript:` URLs are **rejected** — the logo will be silently omitted — because SVG data-URIs can carry script.
|
|
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckystack/docs-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -55,10 +55,10 @@
|
|
|
55
55
|
"test": "vitest run"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@luckystack/core": "^0.
|
|
58
|
+
"@luckystack/core": "^0.5.0"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@luckystack/server": "^0.
|
|
61
|
+
"@luckystack/server": "^0.5.0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependenciesMeta": {
|
|
64
64
|
"@luckystack/server": {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/docsHtml.ts"],"sourcesContent":["//? Dev-only API docs UI for LuckyStack.\n//?\n//? Mounts a single page at `/_docs` (configurable) that fetches the\n//? framework's `apiDocs.generated.json` and renders every API endpoint\n//? grouped by page, with method, auth, rate limit, input shape, and\n//? output shape.\n//?\n//? Usage:\n//? // luckystack/docs-ui/index.ts\n//? import { mountDocsUi } from '@luckystack/docs-ui';\n//? mountDocsUi(); // Auto-disables in production unless { enabledInProd: true }\n//?\n//? The mount function returns a `customRoutes` handler — pass it to\n//? `createLuckyStackServer({ customRoutes })` (or compose with another\n//? handler if you have one). It honors the request only when the URL\n//? matches the configured `routePath`; everything else falls through.\n\nimport fs from 'node:fs/promises';\nimport type { IncomingMessage, ServerResponse } from 'node:http';\nimport { getBindAddress, getGeneratedApiDocsPath, isLoopbackIp, resolveEnvKey, tryCatch } from '@luckystack/core';\nimport { renderDocsHtml } from './docsHtml';\n\nexport interface DocsBranding {\n /**\n * Logo URL shown in the header. Accepts `https:`, `http:`, and scheme-free\n * relative URLs. `data:` and `javascript:` URLs are rejected (the logo will\n * be silently omitted) because SVG data-URIs can carry script.\n */\n logoUrl?: string;\n /** CSS color for the header / accent (`#hex` or `rgb()` literal). */\n brandColor?: string;\n /** Web-safe font family for the page. */\n fontFamily?: string;\n}\n\n/**\n * Custom HTML builder. Receives the JSON-endpoint path the page should\n * fetch from + page title + branding; returns the full HTML document\n * (`<!doctype html>` + everything). Use to swap layout entirely (sidebar\n * vs tabs, dark/light theme, embedded auth-token field).\n */\nexport type DocsTemplateBuilder = (input: {\n jsonPath: string;\n pageTitle: string;\n branding: DocsBranding;\n}) => string;\n\nexport interface MountDocsUiOptions {\n /** Path the docs UI is served from. Default: `/_docs`. */\n routePath?: string;\n /**\n * Page title shown in the header + browser tab. Defaults to\n * \"LuckyStack — API docs\". Pass an explicit string to brand for a consumer.\n */\n pageTitle?: string;\n /**\n * Allow rendering the docs UI in production. Default: false. Useful only\n * when the docs are intentionally public (developer-portal, internal-only\n * deployment, etc.). When false, the route returns 404 in production.\n */\n enabledInProd?: boolean;\n /**\n * Override the path to `apiDocs.generated.json`. Defaults to whatever\n * `getGeneratedApiDocsPath()` resolves to via ProjectConfig.paths.\n */\n apiDocsPath?: string;\n /**\n * Optional branding inputs (logo, brand color, font). Applied by the\n * default template. Custom templates may ignore these.\n */\n branding?: DocsBranding;\n /**\n * Custom HTML template builder. When provided, the default `renderDocsHtml`\n * is bypassed entirely. Use for radically different layouts (sidebar,\n * tabs, dark mode, marketing-page-style). Receives a tested JSON path +\n * title + branding so your template doesn't need to know about\n * `getGeneratedApiDocsPath`.\n */\n template?: DocsTemplateBuilder;\n /**\n * Enable the inline \"try-it-out\" runner. When true, the default template\n * renders a request-input box + send button per endpoint that calls\n * `apiRequest` against the live server. Off by default because the\n * runner needs a logged-in session.\n */\n enableTryItOut?: boolean;\n /**\n * Optional per-request authorization hook. Called after the env/bind-address\n * gate passes. Return `true` to allow the request, `false` to serve 403.\n * Use to restrict docs access to authenticated or IP-allowlisted callers on\n * non-loopback deployments.\n */\n authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;\n}\n\nexport type DocsRouteHandler = (\n req: IncomingMessage,\n res: ServerResponse,\n) => Promise<boolean>;\n\n/**\n * Returns a route handler compatible with `createLuckyStackServer`'s\n * `customRoutes` option. The handler returns `true` (response sent) when\n * the URL matches the configured docs route, `false` otherwise.\n */\nexport const mountDocsUi = (options: MountDocsUiOptions = {}): DocsRouteHandler => {\n const routePath = options.routePath ?? '/_docs';\n const jsonPath = `${routePath}/api.json`;\n\n return async (req, res) => {\n const url = req.url ?? '';\n const [pathOnly] = url.split('?');\n\n if (pathOnly !== routePath && pathOnly !== jsonPath) {\n return false;\n }\n\n //? Fail-closed on production env OR any non-loopback bind address unless the\n //? consumer explicitly opts in. Staging/preview servers that bind to a public\n //? interface must set `enabledInProd` to avoid exposing the docs route.\n const isPublicBind = !isLoopbackIp(getBindAddress().ip);\n if ((resolveEnvKey() === 'production' || isPublicBind) && !options.enabledInProd) {\n res.statusCode = 404;\n res.setHeader('Content-Type', 'text/plain');\n res.end('Not Found');\n return true;\n }\n\n if (options.authorize) {\n const allowed = await options.authorize(req);\n if (!allowed) {\n res.statusCode = 403;\n res.setHeader('Content-Type', 'text/plain');\n res.end('Forbidden');\n return true;\n }\n }\n\n if (req.method !== 'GET') {\n res.statusCode = 405;\n res.setHeader('Content-Type', 'text/plain');\n res.setHeader('Allow', 'GET');\n res.end('Method Not Allowed');\n return true;\n }\n\n if (pathOnly === jsonPath) {\n const docsPath = options.apiDocsPath ?? getGeneratedApiDocsPath();\n const [readError, content] = await tryCatch(() => fs.readFile(docsPath, 'utf8'));\n if (readError) {\n res.statusCode = 404;\n res.setHeader('Content-Type', 'application/json; charset=utf-8');\n //? Only expose the absolute filesystem path in non-production envs to\n //? avoid leaking internal directory structure to callers (DOCSUI-8).\n //? Use the framework-canonical env resolver (honors LUCKYSTACK_ENV),\n //? mirroring the production gate above — not raw process.env.NODE_ENV.\n const isDev = resolveEnvKey() !== 'production';\n res.end(JSON.stringify({\n error: 'apiDocs.generated.json not found',\n ...(isDev ? { expectedAt: docsPath } : {}),\n hint: 'Run `npm run generateArtifacts` to generate it.',\n }));\n } else {\n //? Validate JSON before serving so a torn/corrupt artifact surfaces a\n //? meaningful 422 rather than a 200 with garbled payload that renders\n //? as \"Could not load API docs\" in the browser with no actionable hint\n //? (DD-DOCSUI-17). We parse but serve the original bytes — no\n //? round-trip serialization that could change whitespace or key order.\n try {\n JSON.parse(content ?? '');\n } catch {\n res.statusCode = 422;\n res.setHeader('Content-Type', 'application/json; charset=utf-8');\n res.end(JSON.stringify({\n error: 'apiDocs.generated.json is not valid JSON (file may be torn/corrupted)',\n hint: 'Run `npm run generateArtifacts` to regenerate it.',\n }));\n return true;\n }\n res.statusCode = 200;\n res.setHeader('Content-Type', 'application/json; charset=utf-8');\n res.setHeader('Cache-Control', 'no-store');\n res.end(content);\n }\n return true;\n }\n\n //? Serve the HTML page itself. Title + branding + custom template\n //? layered: explicit `template` builder wins over the built-in renderer.\n //? Defaults render via `renderDocsHtml(jsonPath, title, { branding, enableTryItOut })`.\n const title = options.pageTitle ?? 'LuckyStack — API docs';\n const branding = options.branding ?? {};\n res.statusCode = 200;\n res.setHeader('Content-Type', 'text/html; charset=utf-8');\n res.setHeader('Cache-Control', 'no-store');\n res.setHeader('X-Content-Type-Options', 'nosniff');\n res.setHeader('X-Frame-Options', 'DENY');\n const html = options.template\n ? options.template({ jsonPath, pageTitle: title, branding })\n : renderDocsHtml(jsonPath, title, {\n branding,\n enableTryItOut: options.enableTryItOut ?? false,\n });\n res.end(html);\n return true;\n };\n};\n","//? Embedded HTML for the docs page. Self-contained — no build-time bundler\n//? needed. Fetches `<routePath>/api.json` at runtime and renders the API\n//? catalog grouped by page. Style mirrors the framework's Tailwind tokens\n//? (background / container / muted / common) loosely so it doesn't look\n//? wildly out of place in projects that haven't customized them.\n\nimport { escapeHtml } from '@luckystack/core';\n\nexport interface RenderDocsHtmlOptions {\n branding?: {\n logoUrl?: string;\n brandColor?: string;\n fontFamily?: string;\n };\n /**\n * When true, the renderer injects an inline \"try-it-out\" form per\n * endpoint. The form calls `apiRequest` from `@luckystack/core` against\n * the live server. Disabled by default (requires a logged-in session).\n */\n enableTryItOut?: boolean;\n}\n\n//? Strip CSS-break characters from a value before injecting it into a\n//? <style> block. Removes `}` (would close the rule-set), `;` (would inject\n//? extra declarations), `<` (would close the <style> element), newlines\n//? (CR/LF — would break out of the declaration into adjacent lines),\n//? `url(` (would allow loading arbitrary external resources), and CSS comment\n//? delimiters `/*` / `*/` (would allow commenting out surrounding rules).\nconst sanitizeCssValue = (value: string): string =>\n value\n .replaceAll('}', '')\n .replaceAll(';', '')\n .replaceAll('<', '')\n .replaceAll('\\n', '')\n .replaceAll('\\r', '')\n .replaceAll('url(', '')\n .replaceAll('/*', '')\n .replaceAll('*/', '');\n\n//? Only allow http: and https: schemes for the logo URL to block javascript:\n//? and data: XSS vectors. data: URLs are explicitly rejected because an SVG\n//? data-URI can carry script.\nconst isSafeLogoUrl = (url: string): boolean => {\n try {\n const parsed = new URL(url);\n return parsed.protocol === 'https:' || parsed.protocol === 'http:';\n } catch {\n // Relative URLs (no scheme) are safe — they resolve on the same origin.\n // Reject protocol-relative URLs (`//host/...`): they parse-fail here yet the\n // browser loads them from an arbitrary external host over the page scheme.\n return !url.includes(':') && !url.startsWith('//');\n }\n};\n\n//? Renders the `<style>` block. Split out of `renderDocsHtml` so the\n//? document assembler stays readable; `accent` + `fontFamily` are the only\n//? two values interpolated into the stylesheet. Output is byte-identical to\n//? the previously inlined CSS.\nconst renderDocsCss = (accent: string, fontFamily: string): string => `<style>\n :root {\n --bg: #0e1116;\n --container: #161b22;\n --container-hover: #1c232b;\n --border: #2a313c;\n --title: #e6edf3;\n --common: #c9d1d9;\n --muted: #8b949e;\n --accent: ${accent};\n --font-family: ${fontFamily};\n --get: #3fb950;\n --post: ${accent};\n --put: #d29922;\n --delete: #f85149;\n }\n @media (prefers-color-scheme: light) {\n :root {\n --bg: #ffffff;\n --container: #f6f8fa;\n --container-hover: #eaeef2;\n --border: #d0d7de;\n --title: #1f2328;\n --common: #1f2328;\n --muted: #59636e;\n }\n }\n * { box-sizing: border-box; }\n body {\n margin: 0; padding: 0;\n font-family: var(--font-family);\n background: var(--bg); color: var(--common);\n line-height: 1.5;\n }\n .brand-row { display: flex; align-items: center; }\n .try-it-out { margin-top: 12px; padding: 12px; background: var(--container-hover); border: 1px solid var(--border); border-radius: 6px; }\n .try-it-out textarea { width: 100%; min-height: 80px; background: var(--bg); color: var(--common); border: 1px solid var(--border); padding: 6px; font-family: ui-monospace, monospace; font-size: 12px; }\n .try-it-out button { margin-top: 6px; padding: 6px 12px; background: var(--accent); color: white; border: 0; border-radius: 4px; cursor: pointer; }\n .try-it-out pre.result { margin-top: 8px; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; max-height: 240px; overflow: auto; font-size: 12px; }\n .layout {\n max-width: 1200px;\n margin: 0 auto;\n padding: 32px 24px 96px;\n }\n h1 { color: var(--title); margin: 0 0 4px; font-size: 28px; }\n .lead { color: var(--muted); margin: 0 0 32px; font-size: 14px; }\n .summary { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }\n .summary-pill {\n background: var(--container);\n border: 1px solid var(--border);\n padding: 8px 14px;\n border-radius: 8px;\n font-size: 13px;\n }\n .summary-pill strong { color: var(--title); }\n .filter-bar {\n margin-bottom: 24px;\n display: flex;\n gap: 12px;\n align-items: center;\n }\n input[type=\"search\"] {\n flex: 1;\n background: var(--container);\n border: 1px solid var(--border);\n color: var(--common);\n padding: 10px 14px;\n border-radius: 8px;\n font-size: 14px;\n font-family: inherit;\n }\n input[type=\"search\"]:focus {\n outline: none;\n border-color: var(--accent);\n }\n .group {\n margin-bottom: 24px;\n background: var(--container);\n border: 1px solid var(--border);\n border-radius: 12px;\n overflow: hidden;\n }\n .group-header {\n padding: 12px 18px;\n background: var(--container-hover);\n font-size: 13px;\n font-weight: 600;\n color: var(--muted);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n border-bottom: 1px solid var(--border);\n }\n .endpoint {\n padding: 14px 18px;\n border-bottom: 1px solid var(--border);\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Consolas, monospace;\n font-size: 13px;\n cursor: pointer;\n transition: background 80ms;\n }\n .endpoint:last-child { border-bottom: none; }\n .endpoint:hover { background: var(--container-hover); }\n .endpoint-summary { display: flex; align-items: center; gap: 12px; }\n .method {\n display: inline-block;\n min-width: 56px;\n padding: 2px 8px;\n border-radius: 4px;\n font-size: 11px;\n font-weight: 700;\n text-align: center;\n color: #fff;\n }\n .method.GET { background: var(--get); }\n .method.POST { background: var(--post); }\n .method.PUT { background: var(--put); color: #1f2328; }\n .method.DELETE { background: var(--delete); }\n .method.SYNC { background: var(--muted); }\n .badge {\n display: inline-block;\n padding: 1px 6px;\n border-radius: 4px;\n background: var(--container-hover);\n color: var(--muted);\n font-size: 11px;\n margin-right: 4px;\n }\n .endpoint-name { color: var(--title); font-weight: 500; }\n .endpoint-meta { margin-left: auto; color: var(--muted); font-size: 12px; }\n .endpoint-detail {\n margin-top: 12px;\n padding: 12px;\n background: var(--bg);\n border-radius: 8px;\n border: 1px solid var(--border);\n display: none;\n white-space: pre-wrap;\n word-break: break-word;\n }\n .endpoint.open .endpoint-detail { display: block; }\n .detail-section { margin-bottom: 12px; }\n .detail-section:last-child { margin-bottom: 0; }\n .detail-label {\n color: var(--muted);\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n margin-bottom: 4px;\n }\n pre {\n margin: 0;\n color: var(--common);\n font-size: 12px;\n overflow-x: auto;\n }\n .auth-tag {\n display: inline-block;\n padding: 1px 6px;\n border-radius: 4px;\n background: var(--container-hover);\n color: var(--muted);\n font-size: 11px;\n margin-right: 4px;\n }\n .empty {\n padding: 48px;\n text-align: center;\n color: var(--muted);\n }\n a.json-link { color: var(--accent); }\n</style>`;\n\n//? Renders the self-contained client `<script>` block. Split out of\n//? `renderDocsHtml` for readability; `jsonPath` (fetch target, injected as a\n//? JSON.stringify literal) and `tryItOutData` (`'true'`/`'false'`) are the\n//? only two interpolations. This is browser-runtime JavaScript embedded as a\n//? string — it cannot import from `@luckystack/core`, so it carries its own\n//? minimal `escapeHtml` mirror of core's escaping. Output is byte-identical\n//? to the previously inlined script.\n//? Escaping the embedded JSON path's less-than sign to its unicode escape so a\n//? jsonPath (consumer-configured routePath) containing a closing script-tag\n//? sequence cannot terminate this inline script block and inject markup.\n//? JSON.stringify alone escapes quotes but not that sequence — standard\n//? JSON-in-HTML hardening. (Comment kept OUTSIDE the template literal so it\n//? doesn't itself land in the emitted HTML.)\n//? The 6-char sequence backslash-u-003c (the backslash built from code point 0x5c\n//? to avoid an escaped-backslash string literal). Emitted into the JS so the browser\n//? parses it back to `<` at runtime while the HTML parser never sees a literal `<`\n//? (so a closing script-tag sequence inside jsonPath can't break out of the block).\nconst LT_UNICODE_ESCAPE = String.fromCodePoint(0x5C) + 'u003c';\nconst renderDocsScript = (jsonPath: string, tryItOutData: string): string => `<script>\n const JSON_PATH = ${JSON.stringify(jsonPath).replaceAll('<', LT_UNICODE_ESCAPE)};\n const ENABLE_TRY_IT_OUT = ${tryItOutData};\n const stateByKey = new Map();\n\n //? Page-session CSRF token cache. In the default cookie-mode every\n //? state-changing request (POST/PUT/DELETE) must echo the token from\n //? GET /auth/csrf as the x-csrf-token header, or the server 403s it. We\n //? fetch once and reuse; a failed fetch leaves the cache null so a later\n //? attempt can retry rather than poisoning the session.\n let cachedCsrfToken = null;\n const getCsrfToken = async () => {\n if (cachedCsrfToken !== null) return cachedCsrfToken;\n try {\n const r = await fetch('/auth/csrf', { credentials: 'include' });\n if (!r.ok) return null;\n const body = await r.json();\n cachedCsrfToken = (body && body.csrfToken) ? body.csrfToken : null;\n } catch {\n cachedCsrfToken = null;\n }\n return cachedCsrfToken;\n };\n\n //? Inline runner: hits the live server using the same fetch transport\n //? that the framework apiRequest helper uses. Auth comes from the\n //? browser's existing session cookie or sessionStorage; no token prompt.\n const runEndpoint = async (button, route, method, dataField) => {\n //? The framework enforces the route's declared HTTP method (405 on\n //? mismatch), so the runner must send that method, not a hardcoded POST.\n const httpMethod = (method || 'POST').toUpperCase();\n const hasBody = httpMethod !== 'GET' && httpMethod !== 'DELETE';\n const isStateChanging = httpMethod !== 'GET';\n let parsed;\n try {\n parsed = dataField.value.trim().length === 0 ? {} : JSON.parse(dataField.value);\n } catch (err) {\n const resultEl = button.parentElement.querySelector('pre.result');\n resultEl.textContent = 'JSON parse error: ' + err.message;\n return;\n }\n button.disabled = true;\n const resultEl = button.parentElement.querySelector('pre.result');\n resultEl.textContent = 'Sending...';\n try {\n //? GET/DELETE carry no body — serialize the parsed input into the query\n //? string instead so the user's input is not silently dropped. Non-string\n //? values are JSON-encoded so objects/numbers survive the round-trip.\n const params = new URLSearchParams();\n params.set('stream', 'false');\n if (!hasBody) {\n for (const [k, v] of Object.entries(parsed)) {\n params.set(k, typeof v === 'string' ? v : JSON.stringify(v));\n }\n }\n const headers = hasBody ? { 'Content-Type': 'application/json' } : {};\n //? Attach the CSRF token to state-changing requests (cookie-mode default).\n if (isStateChanging) {\n const csrf = await getCsrfToken();\n if (csrf) headers['x-csrf-token'] = csrf;\n }\n const response = await fetch('/' + route + '?' + params.toString(), {\n method: httpMethod,\n headers,\n credentials: 'include',\n body: hasBody ? JSON.stringify(parsed) : undefined,\n });\n const text = await response.text();\n try {\n resultEl.textContent = JSON.stringify(JSON.parse(text), null, 2);\n } catch {\n resultEl.textContent = text;\n }\n } catch (err) {\n resultEl.textContent = 'Network error: ' + err.message;\n } finally {\n button.disabled = false;\n }\n };\n\n const renderTryItOut = (route, method) => {\n if (!ENABLE_TRY_IT_OUT) return '';\n return '<div class=\"try-it-out\">' +\n '<div style=\"font-weight:600;font-size:12px;margin-bottom:4px;\">Try it out (live request)</div>' +\n '<textarea placeholder=\\\\'{\"key\":\"value\"}\\\\'>{}</textarea>' +\n '<button onclick=\"runEndpoint(this,\\\\'' + escapeHtml(route) + '\\\\',\\\\'' + escapeHtml(method) + '\\\\',this.previousElementSibling)\">Send</button>' +\n '<pre class=\"result\"></pre>' +\n '</div>';\n };\n\n const escapeHtml = (str) => String(str).replace(/[&<>\"']/g, (c) => ({\n '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": '''\n })[c]);\n\n const renderAuth = (auth) => {\n if (!auth) return '<span class=\"auth-tag\">public</span>';\n const tags = [];\n if (auth.login) tags.push('login required');\n if (Array.isArray(auth.additional)) {\n for (const rule of auth.additional) {\n if (rule && rule.key) tags.push(rule.key + (rule.value !== undefined ? '=' + JSON.stringify(rule.value) : ''));\n }\n }\n if (tags.length === 0) tags.push('public');\n return tags.map(t => '<span class=\"auth-tag\">' + escapeHtml(t) + '</span>').join('');\n };\n\n const renderEndpoint = (entry) => {\n const page = entry.page || '';\n const name = entry.name || '';\n const version = entry.version || '';\n const key = page + '/' + name + '@' + version;\n const method = (entry.method || 'POST').toUpperCase();\n const methodClass = ['GET', 'POST', 'PUT', 'DELETE'].includes(method) ? method : 'POST';\n const rate = entry.rateLimit;\n const rateLabel = rate === false ? 'no rate limit' : rate === undefined ? 'default rate' : (rate + '/min');\n const isOpen = stateByKey.get(key) === true;\n const path = '/api/' + page + '/' + name + '/' + version;\n const meta = entry.meta || {};\n return \\`\n <div class=\"endpoint \\${isOpen ? 'open' : ''}\" data-key=\"\\${escapeHtml(key)}\">\n <div class=\"endpoint-summary\">\n <span class=\"method \\${methodClass}\">\\${escapeHtml(method)}</span>\n <span class=\"endpoint-name\">\\${escapeHtml(path)}</span>\n <span class=\"endpoint-meta\">\\${escapeHtml(rateLabel)}</span>\n </div>\n <div class=\"endpoint-detail\">\n <div class=\"detail-section\">\n <div class=\"detail-label\">auth</div>\n <div>\\${renderAuth(entry.auth)}</div>\n </div>\n <div class=\"detail-section\">\n <div class=\"detail-label\">input</div>\n <pre>\\${escapeHtml(entry.input || '{}')}</pre>\n </div>\n <div class=\"detail-section\">\n <div class=\"detail-label\">output</div>\n <pre>\\${escapeHtml(entry.output || 'unknown')}</pre>\n </div>\n \\${entry.stream && entry.stream !== 'never' ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">stream</div>\n <pre>\\${escapeHtml(entry.stream)}</pre>\n </div>\n \\` : ''}\n \\${meta.owner ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">owner</div>\n <div>\\${escapeHtml(meta.owner)}</div>\n </div>\n \\` : ''}\n \\${Array.isArray(meta.tags) && meta.tags.length ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">tags</div>\n <div>\\${meta.tags.map(t => '<span class=\"badge\">' + escapeHtml(String(t)) + '</span>').join(' ')}</div>\n </div>\n \\` : ''}\n \\${meta.deprecated ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">deprecated</div>\n <div style=\"color:var(--delete);\">\\${escapeHtml(String(meta.deprecated))}</div>\n </div>\n \\` : ''}\n \\${renderTryItOut('api/' + page + '/' + name + '/' + version, method)}\n </div>\n </div>\n \\`;\n };\n\n const renderSyncEntry = (entry) => {\n const page = entry.page || '';\n const name = entry.name || '';\n const version = entry.version || '';\n const key = 'sync:' + page + '/' + name + '@' + version;\n const isOpen = stateByKey.get(key) === true;\n const path = 'sync/' + page + '/' + name + '/' + version;\n const meta = entry.meta || {};\n const section = (label, value) => value && value !== 'never'\n ? '<div class=\"detail-section\"><div class=\"detail-label\">' + escapeHtml(label) + '</div><pre>' + escapeHtml(value) + '</pre></div>'\n : '';\n return \\`\n <div class=\"endpoint \\${isOpen ? 'open' : ''}\" data-key=\"\\${escapeHtml(key)}\">\n <div class=\"endpoint-summary\">\n <span class=\"method SYNC\">SYNC</span>\n <span class=\"endpoint-name\">\\${escapeHtml(path)}</span>\n </div>\n <div class=\"endpoint-detail\">\n \\${section('client input', entry.clientInput)}\n \\${section('server output', entry.serverOutput)}\n \\${section('client output', entry.clientOutput)}\n \\${section('server stream', entry.serverStream)}\n \\${section('client stream', entry.clientStream)}\n \\${meta.owner ? \\`\n <div class=\"detail-section\">\n <div class=\"detail-label\">owner</div>\n <div>\\${escapeHtml(meta.owner)}</div>\n </div>\n \\` : ''}\n </div>\n </div>\n \\`;\n };\n\n const passesFilter = (filter, page, name) => {\n if (!filter) return true;\n const haystack = (page + '/' + name).toLowerCase();\n return haystack.includes(filter.toLowerCase());\n };\n\n //? Pure (DOM-free) core: walks the FLAT \\`apis[page] = Entry[]\\` and\n //? \\`syncs[page] = SyncEntry[]\\` maps (the real emitted artifact shape),\n //? builds the grouped endpoint markup, and tallies the summary counts.\n //? Extracted from \\`render\\` so the orchestrator stays a thin DOM shell and\n //? the assembly logic can be reasoned about / tested in isolation.\n const buildGroups = (apis, syncs, filter) => {\n let total = 0;\n let visible = 0;\n const groups = [];\n for (const [page, entries] of Object.entries(apis || {})) {\n const rows = [];\n for (const entry of (Array.isArray(entries) ? entries : [])) {\n total++;\n if (!passesFilter(filter, page, entry.name || '')) continue;\n visible++;\n rows.push(renderEndpoint(entry || {}));\n }\n if (rows.length > 0) {\n groups.push(\\`\n <div class=\"group\">\n <div class=\"group-header\">\\${escapeHtml(page)}</div>\n \\${rows.join('')}\n </div>\n \\`);\n }\n }\n for (const [page, entries] of Object.entries(syncs || {})) {\n const rows = [];\n for (const entry of (Array.isArray(entries) ? entries : [])) {\n total++;\n if (!passesFilter(filter, page, entry.name || '')) continue;\n visible++;\n rows.push(renderSyncEntry(entry || {}));\n }\n if (rows.length > 0) {\n groups.push(\\`\n <div class=\"group\">\n <div class=\"group-header\">\\${escapeHtml(page)} (sync)</div>\n \\${rows.join('')}\n </div>\n \\`);\n }\n }\n const pageCount = new Set([...Object.keys(apis || {}), ...Object.keys(syncs || {})]).size;\n const summaryHtml = \\`\n <div class=\"summary-pill\"><strong>\\${visible}</strong> of <strong>\\${total}</strong> endpoints</div>\n <div class=\"summary-pill\"><strong>\\${pageCount}</strong> pages</div>\n \\`;\n const contentHtml = groups.join('') || '<div class=\"empty\">No matches.</div>';\n return { summaryHtml, contentHtml };\n };\n\n //? Event-delegation toggle: one listener on the #content container handles\n //? all endpoint clicks. This replaces the per-element re-bind that was\n //? called on every filter keystroke (DOCSUI-O13).\n const bindEndpointToggles = () => {\n const content = document.getElementById('content');\n content.addEventListener('click', (e) => {\n const el = e.target.closest('.endpoint');\n if (!el) return;\n const key = el.getAttribute('data-key');\n const next = !el.classList.contains('open');\n stateByKey.set(key, next);\n el.classList.toggle('open');\n });\n };\n\n //? Thin orchestrator: resolve the DOM targets, guard the data shape, then\n //? delegate assembly to \\`buildGroups\\`. Toggles are wired once via delegation.\n const render = (data, filter) => {\n const summary = document.getElementById('summary');\n const content = document.getElementById('content');\n //? Accept both the new artifact shape \\`{apis,syncs}\\` and the legacy bare\n //? map \\`{page:Entry[]}\\`. Warn in dev so consumers migrate (DOCSUI-O12).\n const apis = data && data.apis ? data.apis : data;\n const syncs = data && data.syncs ? data.syncs : {};\n if (data && !data.apis && typeof data === 'object' && typeof console !== 'undefined') {\n console.warn('[docs-ui] legacy artifact shape detected (bare map). Re-generate apiDocs.generated.json to get the {apis,syncs} shape.');\n }\n if (!apis || typeof apis !== 'object') {\n content.innerHTML = '<div class=\"empty\">No API docs available. Run <code>npm run generateArtifacts</code> to generate them.</div>';\n summary.innerHTML = '';\n return;\n }\n const built = buildGroups(apis, syncs, filter);\n summary.innerHTML = built.summaryHtml;\n content.innerHTML = built.contentHtml;\n };\n\n let cachedData = null;\n //? Delegation is registered once; filter keystrokes only rebuild innerHTML.\n bindEndpointToggles();\n document.getElementById('filter').addEventListener('input', (e) => {\n if (cachedData) render(cachedData, e.target.value);\n });\n\n //? On non-OK responses, try to surface the JSON error body's \\`error\\` field\n //? rather than just the raw status code (DOCSUI-O14).\n fetch(JSON_PATH, { credentials: 'include' })\n .then(r => r.ok ? r.json() : r.json().then(\n (j) => Promise.reject(new Error((j && j.error) ? j.error : String(r.status))),\n () => Promise.reject(new Error(String(r.status)))\n ))\n .then(data => { cachedData = data; render(data, ''); })\n .catch(err => {\n document.getElementById('content').innerHTML =\n '<div class=\"empty\">Could not load API docs: ' + escapeHtml(err.message) + '</div>';\n });\n</script>`;\n\nexport const renderDocsHtml = (\n jsonPath: string,\n pageTitle: string,\n options: RenderDocsHtmlOptions = {},\n): string => {\n const branding = options.branding ?? {};\n //? Sanitize CSS-injected values to prevent rule-set / style-element breakout\n //? (DOCSUI-O9). Sanitize logoUrl scheme to block javascript:/data: XSS (DOCSUI-O11).\n const accent = sanitizeCssValue(branding.brandColor ?? '#58a6ff');\n const fontFamily = sanitizeCssValue(branding.fontFamily ?? `system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif`);\n const logoMarkup = branding.logoUrl && isSafeLogoUrl(branding.logoUrl)\n ? `<img src=\"${escapeHtml(branding.logoUrl)}\" alt=\"logo\" style=\"height:32px;width:auto;margin-right:12px;\" />`\n : '';\n const tryItOutData = options.enableTryItOut ? 'true' : 'false';\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>${escapeHtml(pageTitle)}</title>\n${renderDocsCss(accent, fontFamily)}\n</head>\n<body>\n<div class=\"layout\">\n <div class=\"brand-row\">${logoMarkup}<h1>${escapeHtml(pageTitle)}</h1></div>\n <p class=\"lead\">Generated from <code>apiDocs.generated.json</code>. Raw JSON: <a class=\"json-link\" href=\"${escapeHtml(jsonPath)}\">${escapeHtml(jsonPath)}</a></p>\n <div class=\"summary\" id=\"summary\"></div>\n <div class=\"filter-bar\">\n <input type=\"search\" id=\"filter\" placeholder=\"Filter by route name…\" autofocus />\n </div>\n <div id=\"content\"></div>\n</div>\n${renderDocsScript(jsonPath, tryItOutData)}\n</body>\n</html>`;\n};\n"],"mappings":"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","names":[]}
|