vocs 2.0.17 → 2.1.2

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.
Files changed (306) hide show
  1. package/dist/config.d.ts +1 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +1 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/globals.d.ts +16 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/internal/config.d.ts +28 -0
  11. package/dist/internal/config.d.ts.map +1 -1
  12. package/dist/internal/config.js +10 -2
  13. package/dist/internal/config.js.map +1 -1
  14. package/dist/internal/llms.d.ts +21 -2
  15. package/dist/internal/llms.d.ts.map +1 -1
  16. package/dist/internal/llms.js +41 -1
  17. package/dist/internal/llms.js.map +1 -1
  18. package/dist/internal/markdown-negotiation.d.ts +36 -0
  19. package/dist/internal/markdown-negotiation.d.ts.map +1 -0
  20. package/dist/internal/markdown-negotiation.js +81 -0
  21. package/dist/internal/markdown-negotiation.js.map +1 -0
  22. package/dist/internal/markdown.d.ts.map +1 -1
  23. package/dist/internal/markdown.js +5 -0
  24. package/dist/internal/markdown.js.map +1 -1
  25. package/dist/internal/openapi/anchors.d.ts +25 -0
  26. package/dist/internal/openapi/anchors.d.ts.map +1 -0
  27. package/dist/internal/openapi/anchors.js +37 -0
  28. package/dist/internal/openapi/anchors.js.map +1 -0
  29. package/dist/internal/openapi/app.d.ts +89 -0
  30. package/dist/internal/openapi/app.d.ts.map +1 -0
  31. package/dist/internal/openapi/app.js +62 -0
  32. package/dist/internal/openapi/app.js.map +1 -0
  33. package/dist/internal/openapi/index.d.ts +8 -0
  34. package/dist/internal/openapi/index.d.ts.map +1 -0
  35. package/dist/internal/openapi/index.js +5 -0
  36. package/dist/internal/openapi/index.js.map +1 -0
  37. package/dist/internal/openapi/markdown.d.ts +42 -0
  38. package/dist/internal/openapi/markdown.d.ts.map +1 -0
  39. package/dist/internal/openapi/markdown.js +235 -0
  40. package/dist/internal/openapi/markdown.js.map +1 -0
  41. package/dist/internal/openapi/openapi.d.ts +187 -0
  42. package/dist/internal/openapi/openapi.d.ts.map +1 -0
  43. package/dist/internal/openapi/openapi.js +44 -0
  44. package/dist/internal/openapi/openapi.js.map +1 -0
  45. package/dist/internal/openapi/openrpc.d.ts +90 -0
  46. package/dist/internal/openapi/openrpc.d.ts.map +1 -0
  47. package/dist/internal/openapi/openrpc.js +213 -0
  48. package/dist/internal/openapi/openrpc.js.map +1 -0
  49. package/dist/internal/openapi/parser.d.ts +181 -0
  50. package/dist/internal/openapi/parser.d.ts.map +1 -0
  51. package/dist/internal/openapi/parser.js +329 -0
  52. package/dist/internal/openapi/parser.js.map +1 -0
  53. package/dist/internal/openapi/registry.d.ts +36 -0
  54. package/dist/internal/openapi/registry.d.ts.map +1 -0
  55. package/dist/internal/openapi/registry.js +79 -0
  56. package/dist/internal/openapi/registry.js.map +1 -0
  57. package/dist/internal/openapi/sample.d.ts +115 -0
  58. package/dist/internal/openapi/sample.d.ts.map +1 -0
  59. package/dist/internal/openapi/sample.js +434 -0
  60. package/dist/internal/openapi/sample.js.map +1 -0
  61. package/dist/internal/openapi/search.d.ts +19 -0
  62. package/dist/internal/openapi/search.d.ts.map +1 -0
  63. package/dist/internal/openapi/search.js +98 -0
  64. package/dist/internal/openapi/search.js.map +1 -0
  65. package/dist/internal/openapi/sidebar.d.ts +30 -0
  66. package/dist/internal/openapi/sidebar.d.ts.map +1 -0
  67. package/dist/internal/openapi/sidebar.js +67 -0
  68. package/dist/internal/openapi/sidebar.js.map +1 -0
  69. package/dist/internal/openapi/union.d.ts +36 -0
  70. package/dist/internal/openapi/union.d.ts.map +1 -0
  71. package/dist/internal/openapi/union.js +69 -0
  72. package/dist/internal/openapi/union.js.map +1 -0
  73. package/dist/internal/search.d.ts.map +1 -1
  74. package/dist/internal/search.js +20 -0
  75. package/dist/internal/search.js.map +1 -1
  76. package/dist/internal/vite-plugins.d.ts +12 -0
  77. package/dist/internal/vite-plugins.d.ts.map +1 -1
  78. package/dist/internal/vite-plugins.js +102 -11
  79. package/dist/internal/vite-plugins.js.map +1 -1
  80. package/dist/react/Badge.d.ts +2 -3
  81. package/dist/react/Badge.d.ts.map +1 -1
  82. package/dist/react/Layout.client.d.ts.map +1 -1
  83. package/dist/react/Layout.client.js +2 -2
  84. package/dist/react/Layout.client.js.map +1 -1
  85. package/dist/react/OpenApi.d.ts +6 -0
  86. package/dist/react/OpenApi.d.ts.map +1 -0
  87. package/dist/react/OpenApi.js +6 -0
  88. package/dist/react/OpenApi.js.map +1 -0
  89. package/dist/react/internal/CodeToHtml.client.d.ts +53 -2
  90. package/dist/react/internal/CodeToHtml.client.d.ts.map +1 -1
  91. package/dist/react/internal/CodeToHtml.client.js +154 -21
  92. package/dist/react/internal/CodeToHtml.client.js.map +1 -1
  93. package/dist/react/internal/Sidebar.d.ts.map +1 -1
  94. package/dist/react/internal/Sidebar.js +99 -2
  95. package/dist/react/internal/Sidebar.js.map +1 -1
  96. package/dist/react/internal/openapi/CodeSample.client.d.ts +21 -0
  97. package/dist/react/internal/openapi/CodeSample.client.d.ts.map +1 -0
  98. package/dist/react/internal/openapi/CodeSample.client.js +134 -0
  99. package/dist/react/internal/openapi/CodeSample.client.js.map +1 -0
  100. package/dist/react/internal/openapi/CollapsibleChildren.client.d.ts +17 -0
  101. package/dist/react/internal/openapi/CollapsibleChildren.client.d.ts.map +1 -0
  102. package/dist/react/internal/openapi/CollapsibleChildren.client.js +18 -0
  103. package/dist/react/internal/openapi/CollapsibleChildren.client.js.map +1 -0
  104. package/dist/react/internal/openapi/Disclosure.client.d.ts +28 -0
  105. package/dist/react/internal/openapi/Disclosure.client.d.ts.map +1 -0
  106. package/dist/react/internal/openapi/Disclosure.client.js +38 -0
  107. package/dist/react/internal/openapi/Disclosure.client.js.map +1 -0
  108. package/dist/react/internal/openapi/Endpoints.d.ts +26 -0
  109. package/dist/react/internal/openapi/Endpoints.d.ts.map +1 -0
  110. package/dist/react/internal/openapi/Endpoints.js +33 -0
  111. package/dist/react/internal/openapi/Endpoints.js.map +1 -0
  112. package/dist/react/internal/openapi/EndpointsView.d.ts +24 -0
  113. package/dist/react/internal/openapi/EndpointsView.d.ts.map +1 -0
  114. package/dist/react/internal/openapi/EndpointsView.js +26 -0
  115. package/dist/react/internal/openapi/EndpointsView.js.map +1 -0
  116. package/dist/react/internal/openapi/EnumValues.client.d.ts +14 -0
  117. package/dist/react/internal/openapi/EnumValues.client.d.ts.map +1 -0
  118. package/dist/react/internal/openapi/EnumValues.client.js +20 -0
  119. package/dist/react/internal/openapi/EnumValues.client.js.map +1 -0
  120. package/dist/react/internal/openapi/HeadingAnchor.d.ts +15 -0
  121. package/dist/react/internal/openapi/HeadingAnchor.d.ts.map +1 -0
  122. package/dist/react/internal/openapi/HeadingAnchor.js +12 -0
  123. package/dist/react/internal/openapi/HeadingAnchor.js.map +1 -0
  124. package/dist/react/internal/openapi/OpenApiPage.d.ts +79 -0
  125. package/dist/react/internal/openapi/OpenApiPage.d.ts.map +1 -0
  126. package/dist/react/internal/openapi/OpenApiPage.js +72 -0
  127. package/dist/react/internal/openapi/OpenApiPage.js.map +1 -0
  128. package/dist/react/internal/openapi/Operation.d.ts +25 -0
  129. package/dist/react/internal/openapi/Operation.d.ts.map +1 -0
  130. package/dist/react/internal/openapi/Operation.js +101 -0
  131. package/dist/react/internal/openapi/Operation.js.map +1 -0
  132. package/dist/react/internal/openapi/Playground.client.d.ts +33 -0
  133. package/dist/react/internal/openapi/Playground.client.d.ts.map +1 -0
  134. package/dist/react/internal/openapi/Playground.client.js +170 -0
  135. package/dist/react/internal/openapi/Playground.client.js.map +1 -0
  136. package/dist/react/internal/openapi/PropertyExample.client.d.ts +17 -0
  137. package/dist/react/internal/openapi/PropertyExample.client.d.ts.map +1 -0
  138. package/dist/react/internal/openapi/PropertyExample.client.js +21 -0
  139. package/dist/react/internal/openapi/PropertyExample.client.js.map +1 -0
  140. package/dist/react/internal/openapi/Reference.d.ts +55 -0
  141. package/dist/react/internal/openapi/Reference.d.ts.map +1 -0
  142. package/dist/react/internal/openapi/Reference.js +42 -0
  143. package/dist/react/internal/openapi/Reference.js.map +1 -0
  144. package/dist/react/internal/openapi/Schema.d.ts +110 -0
  145. package/dist/react/internal/openapi/Schema.d.ts.map +1 -0
  146. package/dist/react/internal/openapi/Schema.js +239 -0
  147. package/dist/react/internal/openapi/Schema.js.map +1 -0
  148. package/dist/react/internal/openapi/SchemaUnion.client.d.ts +25 -0
  149. package/dist/react/internal/openapi/SchemaUnion.client.d.ts.map +1 -0
  150. package/dist/react/internal/openapi/SchemaUnion.client.js +48 -0
  151. package/dist/react/internal/openapi/SchemaUnion.client.js.map +1 -0
  152. package/dist/react/internal/openapi/anchor-navigation.client.d.ts +47 -0
  153. package/dist/react/internal/openapi/anchor-navigation.client.d.ts.map +1 -0
  154. package/dist/react/internal/openapi/anchor-navigation.client.js +120 -0
  155. package/dist/react/internal/openapi/anchor-navigation.client.js.map +1 -0
  156. package/dist/react/internal/openapi/auth.d.ts +28 -0
  157. package/dist/react/internal/openapi/auth.d.ts.map +1 -0
  158. package/dist/react/internal/openapi/auth.js +75 -0
  159. package/dist/react/internal/openapi/auth.js.map +1 -0
  160. package/dist/react/useLayout.d.ts +2 -0
  161. package/dist/react/useLayout.d.ts.map +1 -1
  162. package/dist/react/useLayout.js +2 -0
  163. package/dist/react/useLayout.js.map +1 -1
  164. package/dist/server/handlers.d.ts +1 -1
  165. package/dist/server/handlers.d.ts.map +1 -1
  166. package/dist/server/handlers.js +26 -5
  167. package/dist/server/handlers.js.map +1 -1
  168. package/dist/server/og-assets.d.ts +5 -0
  169. package/dist/server/og-assets.d.ts.map +1 -0
  170. package/dist/server/og-assets.js +11 -0
  171. package/dist/server/og-assets.js.map +1 -0
  172. package/dist/server/openapi/assets.d.ts +33 -0
  173. package/dist/server/openapi/assets.d.ts.map +1 -0
  174. package/dist/server/openapi/assets.generated.d.ts +9 -0
  175. package/dist/server/openapi/assets.generated.d.ts.map +1 -0
  176. package/dist/server/openapi/assets.generated.js +1091 -0
  177. package/dist/server/openapi/assets.generated.js.map +1 -0
  178. package/dist/server/openapi/assets.js +32 -0
  179. package/dist/server/openapi/assets.js.map +1 -0
  180. package/dist/server/openapi/handler.d.ts +103 -0
  181. package/dist/server/openapi/handler.d.ts.map +1 -0
  182. package/dist/server/openapi/handler.js +198 -0
  183. package/dist/server/openapi/handler.js.map +1 -0
  184. package/dist/server/openapi/handler.test.d.ts +2 -0
  185. package/dist/server/openapi/handler.test.d.ts.map +1 -0
  186. package/dist/server/openapi/handler.test.js +203 -0
  187. package/dist/server/openapi/handler.test.js.map +1 -0
  188. package/dist/server/openapi/html.d.ts +16 -0
  189. package/dist/server/openapi/html.d.ts.map +1 -0
  190. package/dist/server/openapi/html.js +75 -0
  191. package/dist/server/openapi/html.js.map +1 -0
  192. package/dist/server/openapi/pages.d.ts +33 -0
  193. package/dist/server/openapi/pages.d.ts.map +1 -0
  194. package/dist/server/openapi/pages.js +130 -0
  195. package/dist/server/openapi/pages.js.map +1 -0
  196. package/dist/server/openapi/pages.test.d.ts +2 -0
  197. package/dist/server/openapi/pages.test.d.ts.map +1 -0
  198. package/dist/server/openapi/pages.test.js +94 -0
  199. package/dist/server/openapi/pages.test.js.map +1 -0
  200. package/dist/server/openapi/state.d.ts +42 -0
  201. package/dist/server/openapi/state.d.ts.map +1 -0
  202. package/dist/server/openapi/state.js +101 -0
  203. package/dist/server/openapi/state.js.map +1 -0
  204. package/dist/styles/index.css +16 -0
  205. package/dist/styles/markdown.css +9 -7
  206. package/dist/styles/openapi-playground.css +80 -0
  207. package/dist/styles/openapi.css +660 -0
  208. package/dist/vite.d.ts.map +1 -1
  209. package/dist/vite.js +1 -0
  210. package/dist/vite.js.map +1 -1
  211. package/dist/waku/internal/middleware/md-router.d.ts +0 -4
  212. package/dist/waku/internal/middleware/md-router.d.ts.map +1 -1
  213. package/dist/waku/internal/middleware/md-router.js +3 -48
  214. package/dist/waku/internal/middleware/md-router.js.map +1 -1
  215. package/dist/waku/internal/patches/adapters/vercel-build-enhancer.js +1 -1
  216. package/dist/waku/internal/patches/adapters/vercel-build-enhancer.js.map +1 -1
  217. package/dist/waku/internal/patches/router.d.ts.map +1 -1
  218. package/dist/waku/internal/patches/router.js +114 -1
  219. package/dist/waku/internal/patches/router.js.map +1 -1
  220. package/package.json +5 -1
  221. package/src/config.ts +1 -0
  222. package/src/globals.d.ts +16 -0
  223. package/src/index.ts +1 -0
  224. package/src/internal/config.ts +40 -1
  225. package/src/internal/llms.ts +51 -1
  226. package/src/internal/markdown-negotiation.test.ts +42 -0
  227. package/src/internal/markdown-negotiation.ts +95 -0
  228. package/src/internal/markdown.ts +5 -0
  229. package/src/internal/openapi/anchors.ts +44 -0
  230. package/src/internal/openapi/app.ts +127 -0
  231. package/src/internal/openapi/index.ts +24 -0
  232. package/src/internal/openapi/markdown.test.ts +115 -0
  233. package/src/internal/openapi/markdown.ts +275 -0
  234. package/src/internal/openapi/openapi.ts +212 -0
  235. package/src/internal/openapi/openrpc.test.ts +239 -0
  236. package/src/internal/openapi/openrpc.ts +295 -0
  237. package/src/internal/openapi/parser.test.ts +203 -0
  238. package/src/internal/openapi/parser.ts +613 -0
  239. package/src/internal/openapi/registry.test.ts +89 -0
  240. package/src/internal/openapi/registry.ts +89 -0
  241. package/src/internal/openapi/sample.test.ts +283 -0
  242. package/src/internal/openapi/sample.ts +562 -0
  243. package/src/internal/openapi/search.test.ts +62 -0
  244. package/src/internal/openapi/search.ts +108 -0
  245. package/src/internal/openapi/sidebar.test.ts +131 -0
  246. package/src/internal/openapi/sidebar.ts +94 -0
  247. package/src/internal/openapi/union.test.ts +51 -0
  248. package/src/internal/openapi/union.ts +74 -0
  249. package/src/internal/search.ts +20 -0
  250. package/src/internal/test/virtual-config.stub.ts +14 -0
  251. package/src/internal/vite-plugins.ts +106 -11
  252. package/src/openapi-app/App.tsx +64 -0
  253. package/src/openapi-app/blocks.tsx +33 -0
  254. package/src/openapi-app/client.tsx +25 -0
  255. package/src/openapi-app/links.test.ts +84 -0
  256. package/src/openapi-app/links.ts +66 -0
  257. package/src/openapi-app/payload.ts +20 -0
  258. package/src/openapi-app/virtual/config.ts +7 -0
  259. package/src/openapi-app/virtual/group-icons.ts +2 -0
  260. package/src/openapi-app/virtual/langs.ts +6 -0
  261. package/src/openapi-app/virtual/openapi.ts +10 -0
  262. package/src/openapi-app/virtual/search-index.ts +21 -0
  263. package/src/openapi-app/virtual/slots.ts +4 -0
  264. package/src/openapi-app/virtual/user-styles.ts +2 -0
  265. package/src/openapi-app/waku.tsx +154 -0
  266. package/src/react/Badge.tsx +2 -3
  267. package/src/react/Layout.client.tsx +17 -4
  268. package/src/react/OpenApi.tsx +5 -0
  269. package/src/react/internal/CodeToHtml.client.tsx +283 -22
  270. package/src/react/internal/Sidebar.tsx +126 -22
  271. package/src/react/internal/openapi/CodeSample.client.tsx +294 -0
  272. package/src/react/internal/openapi/CollapsibleChildren.client.tsx +41 -0
  273. package/src/react/internal/openapi/Disclosure.client.tsx +67 -0
  274. package/src/react/internal/openapi/Endpoints.tsx +58 -0
  275. package/src/react/internal/openapi/EndpointsView.tsx +76 -0
  276. package/src/react/internal/openapi/EnumValues.client.tsx +49 -0
  277. package/src/react/internal/openapi/HeadingAnchor.tsx +28 -0
  278. package/src/react/internal/openapi/OpenApiPage.tsx +173 -0
  279. package/src/react/internal/openapi/Operation.test.tsx +101 -0
  280. package/src/react/internal/openapi/Operation.tsx +335 -0
  281. package/src/react/internal/openapi/Playground.client.tsx +234 -0
  282. package/src/react/internal/openapi/PropertyExample.client.tsx +55 -0
  283. package/src/react/internal/openapi/Reference.tsx +120 -0
  284. package/src/react/internal/openapi/Schema.tsx +467 -0
  285. package/src/react/internal/openapi/SchemaUnion.client.tsx +123 -0
  286. package/src/react/internal/openapi/anchor-navigation.client.ts +154 -0
  287. package/src/react/internal/openapi/auth.ts +69 -0
  288. package/src/react/useLayout.ts +4 -0
  289. package/src/server/handlers.ts +31 -6
  290. package/src/server/og-assets.ts +14 -0
  291. package/src/server/openapi/assets.generated.ts +1093 -0
  292. package/src/server/openapi/assets.ts +57 -0
  293. package/src/server/openapi/handler.test.ts +244 -0
  294. package/src/server/openapi/handler.ts +277 -0
  295. package/src/server/openapi/html.ts +84 -0
  296. package/src/server/openapi/pages.test.ts +111 -0
  297. package/src/server/openapi/pages.ts +153 -0
  298. package/src/server/openapi/state.ts +136 -0
  299. package/src/styles/index.css +16 -0
  300. package/src/styles/markdown.css +9 -7
  301. package/src/styles/openapi-playground.css +80 -0
  302. package/src/styles/openapi.css +660 -0
  303. package/src/vite.ts +1 -0
  304. package/src/waku/internal/middleware/md-router.ts +8 -52
  305. package/src/waku/internal/patches/adapters/vercel-build-enhancer.ts +1 -1
  306. package/src/waku/internal/patches/router.ts +131 -1
@@ -0,0 +1,57 @@
1
+ import { assets } from './assets.generated.js'
2
+
3
+ /** A single emitted asset (JS chunk, CSS file, font, …). */
4
+ export type AssetFile = {
5
+ /** MIME type, e.g. `text/javascript; charset=utf-8`. */
6
+ type: string
7
+ /** `utf8` for text assets, `base64` for binary (fonts/wasm). */
8
+ encoding: 'utf8' | 'base64'
9
+ /** File contents (string for `utf8`, base64 string for `base64`). */
10
+ body: string
11
+ }
12
+
13
+ /** The prebuilt standalone bundle manifest. */
14
+ export type Assets = {
15
+ /** Whether the bundle was built (`false` for the committed stub). */
16
+ built: boolean
17
+ /** Entry JS file name (relative to the asset root), e.g. `client.js`. */
18
+ entry: string
19
+ /** CSS file names to inject into the shell `<head>`. */
20
+ styles: string[]
21
+ /** All emitted files, keyed by name relative to the asset root. */
22
+ files: Record<string, AssetFile>
23
+ }
24
+
25
+ /** URL path segment under which standalone assets are served. */
26
+ export const assetRoot = '/_vocs/openapi/'
27
+
28
+ /** Returns the prebuilt bundle manifest. */
29
+ export function manifest(): Assets {
30
+ return assets
31
+ }
32
+
33
+ /**
34
+ * Resolves a request pathname to a bundled asset, matching by the
35
+ * {@link assetRoot} marker so assets resolve regardless of the Hono mount path
36
+ * (e.g. `/docs/_vocs/openapi/client.js` → `client.js`).
37
+ */
38
+ export function match(pathname: string): AssetFile | undefined {
39
+ const index = pathname.lastIndexOf(assetRoot)
40
+ if (index === -1) return undefined
41
+ const name = pathname.slice(index + assetRoot.length)
42
+ return assets.files[name]
43
+ }
44
+
45
+ /** Builds a `Response` for a bundled asset with long-lived caching. */
46
+ export function response(asset: AssetFile): Response {
47
+ const body =
48
+ asset.encoding === 'base64'
49
+ ? Uint8Array.from(atob(asset.body), (c) => c.charCodeAt(0))
50
+ : asset.body
51
+ return new Response(body, {
52
+ headers: {
53
+ 'content-type': asset.type,
54
+ 'cache-control': 'public, max-age=31536000, immutable',
55
+ },
56
+ })
57
+ }
@@ -0,0 +1,244 @@
1
+ import * as fs from 'node:fs/promises'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+ import { Hono } from 'hono'
5
+ import { describe, expect, test } from 'vitest'
6
+ import { openApi } from './handler.js'
7
+
8
+ const spec = {
9
+ openapi: '3.1.0',
10
+ info: { title: 'Demo API', version: '1.0.0', description: 'A demo.' },
11
+ servers: [{ url: 'https://api.example.com' }],
12
+ tags: [{ name: 'Pets', description: 'Pet operations' }],
13
+ paths: {
14
+ '/pets': {
15
+ get: {
16
+ operationId: 'listPets',
17
+ summary: 'List pets',
18
+ tags: ['Pets'],
19
+ responses: { '200': { description: 'OK' } },
20
+ },
21
+ },
22
+ },
23
+ }
24
+
25
+ describe('Handler.openApi', () => {
26
+ test('serves the HTML shell with embedded payload', async () => {
27
+ const ref = openApi({ spec })
28
+ const response = await ref.fetch(new Request('http://localhost/'))
29
+ expect(response.status).toBe(200)
30
+ expect(response.headers.get('content-type')).toContain('text/html')
31
+
32
+ const html = await response.text()
33
+ expect(html).toContain('id="vocs-openapi-data"')
34
+ expect(html).toContain('Demo API')
35
+ // Asset references are absolute, prefixed by the inferred mount (root here).
36
+ expect(html).toContain('/_vocs/openapi/')
37
+ // Embedded payload includes the parsed group.
38
+ expect(html).toContain('"Pets"')
39
+ expect(html).toContain('listPets')
40
+ })
41
+
42
+ test('serves the prebuilt client bundle', async () => {
43
+ const ref = openApi({ spec })
44
+ const response = await ref.fetch(new Request('http://localhost/_vocs/openapi/client.js'))
45
+ expect(response.status).toBe(200)
46
+ expect(response.headers.get('content-type')).toContain('javascript')
47
+ expect(response.headers.get('cache-control')).toContain('immutable')
48
+ const body = await response.text()
49
+ expect(body.length).toBeGreaterThan(0)
50
+ })
51
+
52
+ test('serves assets regardless of mount prefix', async () => {
53
+ const ref = openApi({ spec })
54
+ const response = await ref.fetch(new Request('http://localhost/docs/_vocs/openapi/client.js'))
55
+ expect(response.status).toBe(200)
56
+ expect(response.headers.get('content-type')).toContain('javascript')
57
+ })
58
+
59
+ test('exposes a Node request listener', () => {
60
+ const ref = openApi({ spec })
61
+ expect(typeof ref.listener).toBe('function')
62
+ })
63
+
64
+ test('accepts an already-started spec promise', async () => {
65
+ const ref = openApi({ spec: Promise.resolve(spec) })
66
+ const response = await ref.fetch(new Request('http://localhost/'))
67
+ expect(response.status).toBe(200)
68
+ expect(await response.text()).toContain('listPets')
69
+ })
70
+
71
+ describe('custom css', () => {
72
+ test('injects an inline CSS string into the shell head, after the bundle styles', async () => {
73
+ const ref = openApi({ spec }, { css: ':root { --vocs-color-accent: #7c3aed }' })
74
+ const html = await (await ref.fetch(new Request('http://localhost/'))).text()
75
+ expect(html).toContain('<style>:root { --vocs-color-accent: #7c3aed }</style>')
76
+ // Custom CSS comes after the design-system stylesheets so it wins.
77
+ expect(html.indexOf('rel="stylesheet"')).toBeLessThan(html.indexOf('<style>'))
78
+ })
79
+
80
+ test('omits the inline <style> when no css is configured', async () => {
81
+ const ref = openApi({ spec })
82
+ const html = await (await ref.fetch(new Request('http://localhost/'))).text()
83
+ expect(html).not.toContain('<style>')
84
+ })
85
+
86
+ test('reads css from a { file }', async () => {
87
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'vocs-openapi-css-'))
88
+ await fs.writeFile(path.join(dir, 'theme.css'), '.body { color: red }')
89
+ const ref = openApi({ spec }, { css: { file: 'theme.css' }, rootDir: dir })
90
+ const html = await (await ref.fetch(new Request('http://localhost/'))).text()
91
+ expect(html).toContain('<style>.body { color: red }</style>')
92
+ await fs.rm(dir, { recursive: true, force: true })
93
+ })
94
+
95
+ test('neutralizes a closing </style> tag without corrupting CSS', async () => {
96
+ const ref = openApi(
97
+ { spec },
98
+ { css: 'a > b { color: red } /* </style><script>x</script> */' },
99
+ )
100
+ const html = await (await ref.fetch(new Request('http://localhost/'))).text()
101
+ // `>` combinators are preserved. The closing tags are escaped so they
102
+ // can't break out of the `<style>` block (the opening `<script>` is inert
103
+ // as CSS text once `</style>` can't terminate the block).
104
+ expect(html).toContain('a > b { color: red }')
105
+ expect(html).toContain('<\\/style>')
106
+ expect(html).toContain('<\\/script>')
107
+ expect(html).not.toContain('</style><script>')
108
+ })
109
+ })
110
+
111
+ describe("fallback: 'next'", () => {
112
+ test('renders the reference at the host root, falls through elsewhere', async () => {
113
+ const app = new Hono()
114
+ app.get('/v1/blocks', (c) => c.json({ who: 'api' }))
115
+ app.route('/', openApi({ spec }, { fallback: 'next' }))
116
+ app.notFound((c) => c.json({ error: 'not_found' }, 404))
117
+
118
+ // Reference landing page is owned by the handler.
119
+ const landing = await app.request('http://localhost/')
120
+ expect(landing.status).toBe(200)
121
+ expect(landing.headers.get('content-type')).toContain('text/html')
122
+ // Assets resolve back to the root mount.
123
+ expect(await landing.text()).toContain('/_vocs/openapi/')
124
+
125
+ // A known section route (the "Pets" group) renders the shell too.
126
+ const group = await app.request('http://localhost/pets')
127
+ expect(group.status).toBe(200)
128
+ expect(group.headers.get('content-type')).toContain('text/html')
129
+
130
+ // Bundled assets are served by the handler.
131
+ const asset = await app.request('http://localhost/_vocs/openapi/client.js')
132
+ expect(asset.status).toBe(200)
133
+ expect(asset.headers.get('content-type')).toContain('javascript')
134
+
135
+ // The host API route wins.
136
+ const api = await app.request('http://localhost/v1/blocks')
137
+ expect(api.status).toBe(200)
138
+ expect(await api.json()).toEqual({ who: 'api' })
139
+
140
+ // An unrelated path falls through to the host notFound (not the shell).
141
+ const missing = await app.request('http://localhost/v1/missing')
142
+ expect(missing.status).toBe(404)
143
+ expect(await missing.json()).toEqual({ error: 'not_found' })
144
+ })
145
+
146
+ test('derives the mount prefix from the host basePath', async () => {
147
+ const app = new Hono().basePath('/api')
148
+ app.route('/', openApi({ spec }, { fallback: 'next' }))
149
+
150
+ const landing = await app.request('http://localhost/api/')
151
+ expect(landing.status).toBe(200)
152
+ // Asset URLs are prefixed with the host basePath so they route back here.
153
+ expect(await landing.text()).toContain('/api/_vocs/openapi/')
154
+
155
+ const asset = await app.request('http://localhost/api/_vocs/openapi/client.js')
156
+ expect(asset.status).toBe(200)
157
+ })
158
+ })
159
+
160
+ describe('markdown', () => {
161
+ test('serves the generated overview at `<intro>.md`', async () => {
162
+ const ref = openApi({ spec, path: '/api' })
163
+ const response = await ref.fetch(new Request('http://localhost/api.md'))
164
+ expect(response.status).toBe(200)
165
+ expect(response.headers.get('content-type')).toContain('text/markdown')
166
+
167
+ const md = await response.text()
168
+ expect(md).toContain('# Demo API')
169
+ expect(md).toContain('## Endpoints')
170
+ expect(md).toContain('- [`GET /pets`](/api/pets#listpets): List pets')
171
+ })
172
+
173
+ test('serves a generated category at `<group>.md`', async () => {
174
+ const ref = openApi({ spec, path: '/api' })
175
+ const response = await ref.fetch(new Request('http://localhost/api/pets.md'))
176
+ expect(response.status).toBe(200)
177
+ expect(response.headers.get('content-type')).toContain('text/markdown')
178
+
179
+ const md = await response.text()
180
+ expect(md).toContain('## List pets')
181
+ expect(md).toContain('`GET /pets`')
182
+ })
183
+
184
+ test('honors `Accept: text/markdown` without a `.md` suffix', async () => {
185
+ const ref = openApi({ spec, path: '/api' })
186
+ const response = await ref.fetch(
187
+ new Request('http://localhost/api', { headers: { accept: 'text/markdown' } }),
188
+ )
189
+ expect(response.status).toBe(200)
190
+ expect(response.headers.get('content-type')).toContain('text/markdown')
191
+ expect(await response.text()).toContain('# Demo API')
192
+ })
193
+
194
+ test('serves an authored guide page as its own Markdown', async () => {
195
+ const ref = openApi({
196
+ spec,
197
+ path: '/api',
198
+ pages: [{ path: '/guide', content: '---\ntitle: Guide\n---\n# Guide\n\nHello there.' }],
199
+ })
200
+ const response = await ref.fetch(new Request('http://localhost/api/guide.md'))
201
+ expect(response.status).toBe(200)
202
+ expect(response.headers.get('content-type')).toContain('text/markdown')
203
+ const md = await response.text()
204
+ expect(md).toContain('# Guide')
205
+ expect(md).toContain('Hello there.')
206
+ })
207
+
208
+ test('serves Markdown to terminal clients (curl) without a `.md` suffix', async () => {
209
+ const ref = openApi({ spec, path: '/api' })
210
+ const response = await ref.fetch(
211
+ new Request('http://localhost/api', { headers: { 'user-agent': 'curl/8.4.0' } }),
212
+ )
213
+ expect(response.status).toBe(200)
214
+ expect(response.headers.get('content-type')).toContain('text/markdown')
215
+ expect(await response.text()).toContain('# Demo API')
216
+ })
217
+
218
+ test('serves Markdown to AI agents without a `.md` suffix', async () => {
219
+ const ref = openApi({ spec, path: '/api' })
220
+ const response = await ref.fetch(
221
+ new Request('http://localhost/api', { headers: { 'user-agent': 'ChatGPT-User/2.0' } }),
222
+ )
223
+ expect(response.status).toBe(200)
224
+ expect(response.headers.get('content-type')).toContain('text/markdown')
225
+ })
226
+
227
+ test('does not serve Markdown to search engines without a `.md` suffix', async () => {
228
+ const ref = openApi({ spec, path: '/api' })
229
+ const response = await ref.fetch(
230
+ new Request('http://localhost/api', { headers: { 'user-agent': 'Googlebot/2.1' } }),
231
+ )
232
+ expect(response.headers.get('content-type') ?? '').not.toContain('text/markdown')
233
+ })
234
+
235
+ test('prefixes overview links with the live host mount', async () => {
236
+ const app = new Hono().basePath('/docs')
237
+ app.route('/', openApi({ spec, path: '/api' }, { fallback: 'next' }))
238
+
239
+ const response = await app.request('http://localhost/docs/api.md')
240
+ expect(response.status).toBe(200)
241
+ expect(await response.text()).toContain('](/docs/api/pets#listpets)')
242
+ })
243
+ })
244
+ })
@@ -0,0 +1,277 @@
1
+ import { getRequestListener } from '@hono/node-server'
2
+ import { Hono } from 'hono'
3
+ import { prefersMarkdown } from '../../internal/markdown-negotiation.js'
4
+ import type { Payload } from '../../internal/openapi/app.js'
5
+ import { inferMount, join, knownRoutes } from '../../internal/openapi/app.js'
6
+ import * as Markdown from '../../internal/openapi/markdown.js'
7
+ import type * as OpenApi from '../../internal/openapi/openapi.js'
8
+ import { normalizePath } from '../../internal/openapi/openapi.js'
9
+ import * as Assets from './assets.js'
10
+ import * as Html from './html.js'
11
+ import * as State from './state.js'
12
+
13
+ /**
14
+ * A standalone OpenAPI reference handler: a Hono app augmented with a Node
15
+ * request `listener` so it can be mounted onto a Hono server (`app.route(...)`),
16
+ * served via the Web-standard `fetch`, or run with `node:http`.
17
+ */
18
+ export type Handler<app extends Hono = Hono> = app & {
19
+ /** Node.js `(req, res)` request listener (for `http.createServer`). */
20
+ listener: ReturnType<typeof getRequestListener>
21
+ }
22
+
23
+ /**
24
+ * Creates a standalone, mountable OpenAPI reference (Scalar-style, rendered
25
+ * entirely client-side by a prebuilt browser bundle shipped inside Vocs).
26
+ *
27
+ * Accepts the same config as a `vocs.config.ts` `openapi[]` entry, except `path`
28
+ * is optional (the mount location is provided by the host server; `path` only
29
+ * seeds generated link bases). Supply `pages` to add `.md`/`.mdx` override/guide
30
+ * content to the sidebar.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * import { Hono } from 'hono'
35
+ * import { Handler } from 'vocs/server'
36
+ *
37
+ * const app = new Hono()
38
+ * app.route('/docs', Handler.openApi({ spec: 'https://example.com/openapi.json' }))
39
+ * export default app
40
+ * ```
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * // Node.js
45
+ * import * as http from 'node:http'
46
+ * import { Handler } from 'vocs/server'
47
+ *
48
+ * const ref = Handler.openApi({ spec: './openapi.yaml' })
49
+ * http.createServer(ref.listener).listen(3000)
50
+ * ```
51
+ */
52
+ export function openApi(config: OpenApi.Config, options: openApi.Options = {}): Handler {
53
+ const app = new Hono()
54
+
55
+ // Parse the spec + compile pages once, lazily, and memoize. The first
56
+ // request's origin seeds `baseUrl` so a relative `x-openrpc` URL (e.g.
57
+ // `/openrpc.json`) can be fetched at runtime; the OpenRPC document is
58
+ // host-independent, so reusing the first origin across requests is correct.
59
+ let state: Promise<Payload> | undefined
60
+ const prepare = (baseUrl?: string) => {
61
+ if (!state) state = State.prepare(config, { rootDir: options.rootDir, baseUrl })
62
+ return state
63
+ }
64
+
65
+ // Resolve custom CSS once, lazily, and memoize (a `file` read needs `node:fs`,
66
+ // so only touch it when a file is actually configured).
67
+ let css: Promise<string | undefined> | undefined
68
+ const resolveCss = () => {
69
+ if (!css) css = State.resolveCss(options.css, { rootDir: options.rootDir })
70
+ return css
71
+ }
72
+
73
+ app.get('*', async (c, next) => {
74
+ const { pathname, origin } = new URL(c.req.url)
75
+
76
+ // Serve a bundled asset if the path targets the asset root.
77
+ const asset = Assets.match(pathname)
78
+ if (asset) return Assets.response(asset)
79
+
80
+ const payload = await prepare(origin)
81
+ const routes = knownRoutes(payload)
82
+
83
+ // Serve the Markdown / agent-facing version of a route: `<route>.md`, any
84
+ // route requested with `Accept: text/markdown`, or an AI/terminal client.
85
+ // Shares the exact negotiation rules of the site `.md` router (Open Graph
86
+ // bots and search engines stay on HTML). Generated routes (overview +
87
+ // categories) win over overrides, and authored guide pages serve their own
88
+ // Markdown.
89
+ const wantsMarkdown = prefersMarkdown({
90
+ pathname,
91
+ userAgent: c.req.header('user-agent'),
92
+ accept: c.req.header('accept'),
93
+ })
94
+ if (wantsMarkdown) {
95
+ const basePathname = pathname.endsWith('.md') ? pathname.slice(0, -3) : pathname
96
+ const mount =
97
+ options.fallback === 'next'
98
+ ? mountFromRoutePath(c.req.routePath)
99
+ : inferMount(basePathname, routes)
100
+ const markdown = resolveMarkdown(payload, relativeRoute(basePathname, mount), mount)
101
+ if (markdown) return c.text(markdown, 200, { 'content-type': 'text/markdown; charset=utf-8' })
102
+ // Explicit `.md` for an unknown route falls through to the normal handling
103
+ // below (renders the shell, or `next()` in fallback mode).
104
+ }
105
+
106
+ // Fallthrough mode: only own the reference's own routes (the intro/landing,
107
+ // each group/page, and the asset root above) and defer everything else to
108
+ // the host app via `next()`. This lets the handler be mounted at the host
109
+ // root (`app.route('/', handler)`) alongside a JSON API and a JSON
110
+ // `notFound` without its catch-all swallowing them. The mount prefix comes
111
+ // from Hono's matched `routePath` (e.g. `/api/*`), so it's exact — unlike
112
+ // suffix inference, an API path like `/api/v1/blocks` is never mistaken for
113
+ // a docs route.
114
+ if (options.fallback === 'next') {
115
+ const mount = mountFromRoutePath(c.req.routePath)
116
+ const relative = (pathname.startsWith(mount) ? pathname.slice(mount.length) : pathname) || '/'
117
+ if (!isKnownRoute(relative, routes)) return next()
118
+ }
119
+
120
+ // Otherwise render the HTML shell (client-side router takes over).
121
+ const manifest = Assets.manifest()
122
+ if (!manifest.built)
123
+ return c.text(
124
+ '[vocs] The standalone OpenAPI bundle has not been built. Run `pnpm build` (or install a published build of `vocs`).',
125
+ 500,
126
+ )
127
+ // Infer the host mount prefix so asset URLs route back to this handler
128
+ // regardless of where it is mounted (and independent of a trailing slash).
129
+ const mount =
130
+ options.fallback === 'next'
131
+ ? mountFromRoutePath(c.req.routePath)
132
+ : inferMount(pathname, routes)
133
+ const customCss = await resolveCss()
134
+ // The shell references content-hashed assets, so it must never be cached:
135
+ // a stale shell would point at an old hash (404 after a rebuild), leaving
136
+ // the page rendered but unstyled. Assets themselves stay `immutable`.
137
+ return c.html(Html.render(payload, manifest, mount, customCss), 200, {
138
+ 'cache-control': 'no-store, must-revalidate',
139
+ })
140
+ })
141
+
142
+ return withListener(app)
143
+ }
144
+
145
+ export declare namespace openApi {
146
+ type Options = {
147
+ /**
148
+ * Directory file-path specs/pages are resolved against.
149
+ * @default process.cwd()
150
+ */
151
+ rootDir?: string | undefined
152
+ /**
153
+ * Custom CSS injected into the shell `<head>` as an inline `<style>`, after
154
+ * the design-system stylesheets so it overrides them. Use it to tweak the
155
+ * standalone reference's theme without rebuilding the bundle.
156
+ *
157
+ * - A string of CSS.
158
+ * - `{ file }`: a path to a `.css` file, resolved against `rootDir` and read
159
+ * with `node:fs` (Node only; pass a string on filesystem-less runtimes).
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * Handler.openApi({ spec }, { css: ':root { --vocs-color-accent: #7c3aed }' })
164
+ * ```
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * Handler.openApi({ spec }, { css: { file: './api-theme.css' } })
169
+ * ```
170
+ */
171
+ css?: string | { file: string } | undefined
172
+ /**
173
+ * How to handle requests that don't target one of the reference's own
174
+ * routes (the intro/landing, a group/page, or the asset root).
175
+ *
176
+ * - `'render'` (default): render the HTML shell for any path, so client-side
177
+ * deep links work when the handler owns the whole mount (standalone docs).
178
+ * - `'next'`: call Hono's `next()` so the host app handles the request. Use
179
+ * this when mounting at the host root next to other routes (e.g. a JSON
180
+ * API + JSON `notFound`):
181
+ *
182
+ * @example
183
+ * ```ts
184
+ * app.route('/', Handler.openApi({ spec }, { fallback: 'next' }))
185
+ * ```
186
+ *
187
+ * @default 'render'
188
+ */
189
+ fallback?: 'render' | 'next' | undefined
190
+ }
191
+ }
192
+
193
+ /** Derives the host mount prefix from Hono's matched `routePath` (`/api/*` → `/api`, `/*` → ``). */
194
+ function mountFromRoutePath(routePath: string | undefined): string {
195
+ return (routePath ?? '').replace(/\*+$/, '').replace(/\/+$/, '')
196
+ }
197
+
198
+ /** Whether a mount-relative path is one of the reference's known section routes. */
199
+ function isKnownRoute(relativePath: string, routes: readonly string[]): boolean {
200
+ const path = stripTrailingSlash(relativePath) || '/'
201
+ return routes.some((route) => (stripTrailingSlash(route) || '/') === path)
202
+ }
203
+
204
+ /** Strips the host mount prefix from a pathname, yielding a section route. */
205
+ function relativeRoute(pathname: string, mount: string): string {
206
+ const path = pathname.startsWith(mount) ? pathname.slice(mount.length) : pathname
207
+ return stripTrailingSlash(path) || '/'
208
+ }
209
+
210
+ /**
211
+ * Resolves the Markdown for a section route: the generated overview (intro), a
212
+ * generated category page, or an authored guide page's own Markdown. Returns
213
+ * `null` for unknown routes. `mount` is the live host prefix, prepended to the
214
+ * overview's endpoint links so they resolve back to this handler.
215
+ */
216
+ function resolveMarkdown(payload: Payload, relative: string, mount: string): string | null {
217
+ const base = payload.ir.path || '/'
218
+ const linkBase = `${mount}${base === '/' ? '' : base}`
219
+ const introRoute = base === '/' ? '/' : normalizePath(base)
220
+
221
+ if (relative === introRoute) return Markdown.renderOverview(payload.ir, linkBase)
222
+ for (const group of payload.ir.groups)
223
+ if (relative === join(base, `/${group.id}`)) return Markdown.renderGroup(payload.ir, group)
224
+ for (const page of payload.pages)
225
+ if (page.markdown && relative === join(base, page.path)) return page.markdown
226
+ return null
227
+ }
228
+
229
+ function stripTrailingSlash(value: string): string {
230
+ return value.length > 1 && value.endsWith('/') ? value.slice(0, -1) : value
231
+ }
232
+
233
+ /**
234
+ * Composes multiple handlers onto a single Hono app mounted at `path`
235
+ * (default `/`).
236
+ */
237
+ export function compose(handlers: readonly Handler[], options: compose.Options = {}): Handler {
238
+ const app = new Hono()
239
+ for (const handler of handlers) app.route(options.path ?? '/', handler)
240
+ app.notFound(() => new Response('Not Found', { status: 404 }))
241
+ return withListener(app)
242
+ }
243
+
244
+ export declare namespace compose {
245
+ type Options = {
246
+ /** Mount path for every composed handler. @default "/" */
247
+ path?: string | undefined
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Augments a Hono app with a Node request `listener`.
253
+ *
254
+ * The listener is created lazily on first access. `getRequestListener`
255
+ * (from `@hono/node-server`) overwrites the global `Request`/`Response` with
256
+ * non-configurable Node polyfills the first time it runs; on edge runtimes
257
+ * (Cloudflare Workers, Deno, Bun) that would replace the native `Response` and
258
+ * make every `c.json()`/`c.html()` produce a value the runtime rejects. Edge
259
+ * consumers only ever call `.fetch()` (or mount via `app.route(...)`) and never
260
+ * touch `.listener`, so deferring its creation keeps the global `Response`
261
+ * native there, while Node consumers (`http.createServer(ref.listener)`) get the
262
+ * same behavior as before on first access.
263
+ */
264
+ function withListener<app extends Hono>(app: app): Handler<app> {
265
+ let listener: ReturnType<typeof getRequestListener> | undefined
266
+ // Define the getter directly (not via `Object.assign`, which would invoke it
267
+ // immediately while copying the property and defeat the laziness).
268
+ Object.defineProperty(app, 'listener', {
269
+ configurable: true,
270
+ enumerable: true,
271
+ get() {
272
+ if (!listener) listener = getRequestListener((request) => app.fetch(request))
273
+ return listener
274
+ },
275
+ })
276
+ return app as Handler<app>
277
+ }
@@ -0,0 +1,84 @@
1
+ import type { Payload } from '../../internal/openapi/app.js'
2
+ import { type Assets, assetRoot } from './assets.js'
3
+
4
+ /**
5
+ * Renders the prebuilt app's HTML shell: the real Vocs `<html data-vocs>` root
6
+ * (so the design system + theming apply), a pre-paint theme bootstrap, the
7
+ * embedded JSON payload, and references to the prebuilt browser bundle.
8
+ *
9
+ * Assets are referenced with absolute URLs prefixed by the inferred `mount`, so
10
+ * they route back to the handler regardless of where it is mounted (and
11
+ * independent of a trailing slash on the current URL).
12
+ *
13
+ * `css` is consumer-supplied custom CSS injected as an inline `<style>` after
14
+ * the design-system stylesheets so it overrides them.
15
+ */
16
+ export function render(payload: Payload, assets: Assets, mount: string, css?: string): string {
17
+ const base = `${mount === '/' ? '' : mount}${assetRoot}`
18
+ const styles = assets.styles
19
+ .map((href) => `<link rel="stylesheet" href="${base}${href}">`)
20
+ .join('\n ')
21
+ const userStyles = css ? `\n <style>${escapeStyle(css)}</style>` : ''
22
+ const data = escapeJson(payload)
23
+
24
+ const colorScheme = (payload.config?.colorScheme as string | undefined) ?? 'light dark'
25
+ const accentColor =
26
+ (payload.config?.accentColor as string | undefined) ?? 'light-dark(black, white)'
27
+ const staticTheme = colorScheme === 'light' || colorScheme === 'dark' ? colorScheme : undefined
28
+
29
+ return `<!doctype html>
30
+ <html data-vocs lang="en"${staticTheme ? ` data-vocs-theme="${staticTheme}"` : ''} style="color-scheme: ${escapeAttr(colorScheme)}; --vocs-color-accent: ${escapeAttr(accentColor)}" suppressHydrationWarning>
31
+ <head>
32
+ <meta charset="utf-8" />
33
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
34
+ <title>${escapeHtml(payload.title)}</title>
35
+ <script>${themeBootstrap(colorScheme)}</script>
36
+ ${styles}${userStyles}
37
+ </head>
38
+ <body data-version="1.0">
39
+ <script id="vocs-openapi-data" type="application/json">${data}</script>
40
+ <script type="module" src="${base}${assets.entry}"></script>
41
+ </body>
42
+ </html>`
43
+ }
44
+
45
+ /**
46
+ * Applies the persisted (`localStorage.vocs-theme`) or system color scheme to
47
+ * `<html data-vocs-theme>` / `colorScheme` before first paint (matching
48
+ * {@link file://../../react/Root.client.tsx Root_client}).
49
+ */
50
+ function themeBootstrap(colorScheme: string): string {
51
+ return `(function(){try{var s=${JSON.stringify(colorScheme)};var e=document.documentElement;var t;if(s==='light'||s==='dark')t=s;else{t=localStorage.getItem('vocs-theme');if(t!=='light'&&t!=='dark')t=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}e.setAttribute('data-vocs-theme',t);e.style.colorScheme=t;}catch(_){}})()`
52
+ }
53
+
54
+ /** Escapes a value for safe embedding inside a `<script>` JSON block. */
55
+ function escapeJson(value: unknown): string {
56
+ return JSON.stringify(value)
57
+ .replace(/</g, '\\u003c')
58
+ .replace(/>/g, '\\u003e')
59
+ .replace(/\u2028/g, '\\u2028')
60
+ .replace(/\u2029/g, '\\u2029')
61
+ }
62
+
63
+ /** Escapes text for safe embedding in HTML element content. */
64
+ function escapeHtml(value: string): string {
65
+ return value
66
+ .replace(/&/g, '&amp;')
67
+ .replace(/</g, '&lt;')
68
+ .replace(/>/g, '&gt;')
69
+ .replace(/"/g, '&quot;')
70
+ }
71
+
72
+ /** Escapes a value for safe embedding inside a double-quoted HTML attribute. */
73
+ function escapeAttr(value: string): string {
74
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;')
75
+ }
76
+
77
+ /**
78
+ * Neutralizes sequences that would prematurely close the inline `<style>` block
79
+ * (or smuggle in a `<script>`). CSS itself is left untouched — full HTML
80
+ * escaping would corrupt valid CSS like `>` child combinators.
81
+ */
82
+ function escapeStyle(value: string): string {
83
+ return value.replace(/<\/(style|script)/gi, '<\\/$1')
84
+ }