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.
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/globals.d.ts +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/config.d.ts +28 -0
- package/dist/internal/config.d.ts.map +1 -1
- package/dist/internal/config.js +10 -2
- package/dist/internal/config.js.map +1 -1
- package/dist/internal/llms.d.ts +21 -2
- package/dist/internal/llms.d.ts.map +1 -1
- package/dist/internal/llms.js +41 -1
- package/dist/internal/llms.js.map +1 -1
- package/dist/internal/markdown-negotiation.d.ts +36 -0
- package/dist/internal/markdown-negotiation.d.ts.map +1 -0
- package/dist/internal/markdown-negotiation.js +81 -0
- package/dist/internal/markdown-negotiation.js.map +1 -0
- package/dist/internal/markdown.d.ts.map +1 -1
- package/dist/internal/markdown.js +5 -0
- package/dist/internal/markdown.js.map +1 -1
- package/dist/internal/openapi/anchors.d.ts +25 -0
- package/dist/internal/openapi/anchors.d.ts.map +1 -0
- package/dist/internal/openapi/anchors.js +37 -0
- package/dist/internal/openapi/anchors.js.map +1 -0
- package/dist/internal/openapi/app.d.ts +89 -0
- package/dist/internal/openapi/app.d.ts.map +1 -0
- package/dist/internal/openapi/app.js +62 -0
- package/dist/internal/openapi/app.js.map +1 -0
- package/dist/internal/openapi/index.d.ts +8 -0
- package/dist/internal/openapi/index.d.ts.map +1 -0
- package/dist/internal/openapi/index.js +5 -0
- package/dist/internal/openapi/index.js.map +1 -0
- package/dist/internal/openapi/markdown.d.ts +42 -0
- package/dist/internal/openapi/markdown.d.ts.map +1 -0
- package/dist/internal/openapi/markdown.js +235 -0
- package/dist/internal/openapi/markdown.js.map +1 -0
- package/dist/internal/openapi/openapi.d.ts +187 -0
- package/dist/internal/openapi/openapi.d.ts.map +1 -0
- package/dist/internal/openapi/openapi.js +44 -0
- package/dist/internal/openapi/openapi.js.map +1 -0
- package/dist/internal/openapi/openrpc.d.ts +90 -0
- package/dist/internal/openapi/openrpc.d.ts.map +1 -0
- package/dist/internal/openapi/openrpc.js +213 -0
- package/dist/internal/openapi/openrpc.js.map +1 -0
- package/dist/internal/openapi/parser.d.ts +181 -0
- package/dist/internal/openapi/parser.d.ts.map +1 -0
- package/dist/internal/openapi/parser.js +329 -0
- package/dist/internal/openapi/parser.js.map +1 -0
- package/dist/internal/openapi/registry.d.ts +36 -0
- package/dist/internal/openapi/registry.d.ts.map +1 -0
- package/dist/internal/openapi/registry.js +79 -0
- package/dist/internal/openapi/registry.js.map +1 -0
- package/dist/internal/openapi/sample.d.ts +115 -0
- package/dist/internal/openapi/sample.d.ts.map +1 -0
- package/dist/internal/openapi/sample.js +434 -0
- package/dist/internal/openapi/sample.js.map +1 -0
- package/dist/internal/openapi/search.d.ts +19 -0
- package/dist/internal/openapi/search.d.ts.map +1 -0
- package/dist/internal/openapi/search.js +98 -0
- package/dist/internal/openapi/search.js.map +1 -0
- package/dist/internal/openapi/sidebar.d.ts +30 -0
- package/dist/internal/openapi/sidebar.d.ts.map +1 -0
- package/dist/internal/openapi/sidebar.js +67 -0
- package/dist/internal/openapi/sidebar.js.map +1 -0
- package/dist/internal/openapi/union.d.ts +36 -0
- package/dist/internal/openapi/union.d.ts.map +1 -0
- package/dist/internal/openapi/union.js +69 -0
- package/dist/internal/openapi/union.js.map +1 -0
- package/dist/internal/search.d.ts.map +1 -1
- package/dist/internal/search.js +20 -0
- package/dist/internal/search.js.map +1 -1
- package/dist/internal/vite-plugins.d.ts +12 -0
- package/dist/internal/vite-plugins.d.ts.map +1 -1
- package/dist/internal/vite-plugins.js +102 -11
- package/dist/internal/vite-plugins.js.map +1 -1
- package/dist/react/Badge.d.ts +2 -3
- package/dist/react/Badge.d.ts.map +1 -1
- package/dist/react/Layout.client.d.ts.map +1 -1
- package/dist/react/Layout.client.js +2 -2
- package/dist/react/Layout.client.js.map +1 -1
- package/dist/react/OpenApi.d.ts +6 -0
- package/dist/react/OpenApi.d.ts.map +1 -0
- package/dist/react/OpenApi.js +6 -0
- package/dist/react/OpenApi.js.map +1 -0
- package/dist/react/internal/CodeToHtml.client.d.ts +53 -2
- package/dist/react/internal/CodeToHtml.client.d.ts.map +1 -1
- package/dist/react/internal/CodeToHtml.client.js +154 -21
- package/dist/react/internal/CodeToHtml.client.js.map +1 -1
- package/dist/react/internal/Sidebar.d.ts.map +1 -1
- package/dist/react/internal/Sidebar.js +99 -2
- package/dist/react/internal/Sidebar.js.map +1 -1
- package/dist/react/internal/openapi/CodeSample.client.d.ts +21 -0
- package/dist/react/internal/openapi/CodeSample.client.d.ts.map +1 -0
- package/dist/react/internal/openapi/CodeSample.client.js +134 -0
- package/dist/react/internal/openapi/CodeSample.client.js.map +1 -0
- package/dist/react/internal/openapi/CollapsibleChildren.client.d.ts +17 -0
- package/dist/react/internal/openapi/CollapsibleChildren.client.d.ts.map +1 -0
- package/dist/react/internal/openapi/CollapsibleChildren.client.js +18 -0
- package/dist/react/internal/openapi/CollapsibleChildren.client.js.map +1 -0
- package/dist/react/internal/openapi/Disclosure.client.d.ts +28 -0
- package/dist/react/internal/openapi/Disclosure.client.d.ts.map +1 -0
- package/dist/react/internal/openapi/Disclosure.client.js +38 -0
- package/dist/react/internal/openapi/Disclosure.client.js.map +1 -0
- package/dist/react/internal/openapi/Endpoints.d.ts +26 -0
- package/dist/react/internal/openapi/Endpoints.d.ts.map +1 -0
- package/dist/react/internal/openapi/Endpoints.js +33 -0
- package/dist/react/internal/openapi/Endpoints.js.map +1 -0
- package/dist/react/internal/openapi/EndpointsView.d.ts +24 -0
- package/dist/react/internal/openapi/EndpointsView.d.ts.map +1 -0
- package/dist/react/internal/openapi/EndpointsView.js +26 -0
- package/dist/react/internal/openapi/EndpointsView.js.map +1 -0
- package/dist/react/internal/openapi/EnumValues.client.d.ts +14 -0
- package/dist/react/internal/openapi/EnumValues.client.d.ts.map +1 -0
- package/dist/react/internal/openapi/EnumValues.client.js +20 -0
- package/dist/react/internal/openapi/EnumValues.client.js.map +1 -0
- package/dist/react/internal/openapi/HeadingAnchor.d.ts +15 -0
- package/dist/react/internal/openapi/HeadingAnchor.d.ts.map +1 -0
- package/dist/react/internal/openapi/HeadingAnchor.js +12 -0
- package/dist/react/internal/openapi/HeadingAnchor.js.map +1 -0
- package/dist/react/internal/openapi/OpenApiPage.d.ts +79 -0
- package/dist/react/internal/openapi/OpenApiPage.d.ts.map +1 -0
- package/dist/react/internal/openapi/OpenApiPage.js +72 -0
- package/dist/react/internal/openapi/OpenApiPage.js.map +1 -0
- package/dist/react/internal/openapi/Operation.d.ts +25 -0
- package/dist/react/internal/openapi/Operation.d.ts.map +1 -0
- package/dist/react/internal/openapi/Operation.js +101 -0
- package/dist/react/internal/openapi/Operation.js.map +1 -0
- package/dist/react/internal/openapi/Playground.client.d.ts +33 -0
- package/dist/react/internal/openapi/Playground.client.d.ts.map +1 -0
- package/dist/react/internal/openapi/Playground.client.js +170 -0
- package/dist/react/internal/openapi/Playground.client.js.map +1 -0
- package/dist/react/internal/openapi/PropertyExample.client.d.ts +17 -0
- package/dist/react/internal/openapi/PropertyExample.client.d.ts.map +1 -0
- package/dist/react/internal/openapi/PropertyExample.client.js +21 -0
- package/dist/react/internal/openapi/PropertyExample.client.js.map +1 -0
- package/dist/react/internal/openapi/Reference.d.ts +55 -0
- package/dist/react/internal/openapi/Reference.d.ts.map +1 -0
- package/dist/react/internal/openapi/Reference.js +42 -0
- package/dist/react/internal/openapi/Reference.js.map +1 -0
- package/dist/react/internal/openapi/Schema.d.ts +110 -0
- package/dist/react/internal/openapi/Schema.d.ts.map +1 -0
- package/dist/react/internal/openapi/Schema.js +239 -0
- package/dist/react/internal/openapi/Schema.js.map +1 -0
- package/dist/react/internal/openapi/SchemaUnion.client.d.ts +25 -0
- package/dist/react/internal/openapi/SchemaUnion.client.d.ts.map +1 -0
- package/dist/react/internal/openapi/SchemaUnion.client.js +48 -0
- package/dist/react/internal/openapi/SchemaUnion.client.js.map +1 -0
- package/dist/react/internal/openapi/anchor-navigation.client.d.ts +47 -0
- package/dist/react/internal/openapi/anchor-navigation.client.d.ts.map +1 -0
- package/dist/react/internal/openapi/anchor-navigation.client.js +120 -0
- package/dist/react/internal/openapi/anchor-navigation.client.js.map +1 -0
- package/dist/react/internal/openapi/auth.d.ts +28 -0
- package/dist/react/internal/openapi/auth.d.ts.map +1 -0
- package/dist/react/internal/openapi/auth.js +75 -0
- package/dist/react/internal/openapi/auth.js.map +1 -0
- package/dist/react/useLayout.d.ts +2 -0
- package/dist/react/useLayout.d.ts.map +1 -1
- package/dist/react/useLayout.js +2 -0
- package/dist/react/useLayout.js.map +1 -1
- package/dist/server/handlers.d.ts +1 -1
- package/dist/server/handlers.d.ts.map +1 -1
- package/dist/server/handlers.js +26 -5
- package/dist/server/handlers.js.map +1 -1
- package/dist/server/og-assets.d.ts +5 -0
- package/dist/server/og-assets.d.ts.map +1 -0
- package/dist/server/og-assets.js +11 -0
- package/dist/server/og-assets.js.map +1 -0
- package/dist/server/openapi/assets.d.ts +33 -0
- package/dist/server/openapi/assets.d.ts.map +1 -0
- package/dist/server/openapi/assets.generated.d.ts +9 -0
- package/dist/server/openapi/assets.generated.d.ts.map +1 -0
- package/dist/server/openapi/assets.generated.js +1091 -0
- package/dist/server/openapi/assets.generated.js.map +1 -0
- package/dist/server/openapi/assets.js +32 -0
- package/dist/server/openapi/assets.js.map +1 -0
- package/dist/server/openapi/handler.d.ts +103 -0
- package/dist/server/openapi/handler.d.ts.map +1 -0
- package/dist/server/openapi/handler.js +198 -0
- package/dist/server/openapi/handler.js.map +1 -0
- package/dist/server/openapi/handler.test.d.ts +2 -0
- package/dist/server/openapi/handler.test.d.ts.map +1 -0
- package/dist/server/openapi/handler.test.js +203 -0
- package/dist/server/openapi/handler.test.js.map +1 -0
- package/dist/server/openapi/html.d.ts +16 -0
- package/dist/server/openapi/html.d.ts.map +1 -0
- package/dist/server/openapi/html.js +75 -0
- package/dist/server/openapi/html.js.map +1 -0
- package/dist/server/openapi/pages.d.ts +33 -0
- package/dist/server/openapi/pages.d.ts.map +1 -0
- package/dist/server/openapi/pages.js +130 -0
- package/dist/server/openapi/pages.js.map +1 -0
- package/dist/server/openapi/pages.test.d.ts +2 -0
- package/dist/server/openapi/pages.test.d.ts.map +1 -0
- package/dist/server/openapi/pages.test.js +94 -0
- package/dist/server/openapi/pages.test.js.map +1 -0
- package/dist/server/openapi/state.d.ts +42 -0
- package/dist/server/openapi/state.d.ts.map +1 -0
- package/dist/server/openapi/state.js +101 -0
- package/dist/server/openapi/state.js.map +1 -0
- package/dist/styles/index.css +16 -0
- package/dist/styles/markdown.css +9 -7
- package/dist/styles/openapi-playground.css +80 -0
- package/dist/styles/openapi.css +660 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +1 -0
- package/dist/vite.js.map +1 -1
- package/dist/waku/internal/middleware/md-router.d.ts +0 -4
- package/dist/waku/internal/middleware/md-router.d.ts.map +1 -1
- package/dist/waku/internal/middleware/md-router.js +3 -48
- package/dist/waku/internal/middleware/md-router.js.map +1 -1
- package/dist/waku/internal/patches/adapters/vercel-build-enhancer.js +1 -1
- package/dist/waku/internal/patches/adapters/vercel-build-enhancer.js.map +1 -1
- package/dist/waku/internal/patches/router.d.ts.map +1 -1
- package/dist/waku/internal/patches/router.js +114 -1
- package/dist/waku/internal/patches/router.js.map +1 -1
- package/package.json +5 -1
- package/src/config.ts +1 -0
- package/src/globals.d.ts +16 -0
- package/src/index.ts +1 -0
- package/src/internal/config.ts +40 -1
- package/src/internal/llms.ts +51 -1
- package/src/internal/markdown-negotiation.test.ts +42 -0
- package/src/internal/markdown-negotiation.ts +95 -0
- package/src/internal/markdown.ts +5 -0
- package/src/internal/openapi/anchors.ts +44 -0
- package/src/internal/openapi/app.ts +127 -0
- package/src/internal/openapi/index.ts +24 -0
- package/src/internal/openapi/markdown.test.ts +115 -0
- package/src/internal/openapi/markdown.ts +275 -0
- package/src/internal/openapi/openapi.ts +212 -0
- package/src/internal/openapi/openrpc.test.ts +239 -0
- package/src/internal/openapi/openrpc.ts +295 -0
- package/src/internal/openapi/parser.test.ts +203 -0
- package/src/internal/openapi/parser.ts +613 -0
- package/src/internal/openapi/registry.test.ts +89 -0
- package/src/internal/openapi/registry.ts +89 -0
- package/src/internal/openapi/sample.test.ts +283 -0
- package/src/internal/openapi/sample.ts +562 -0
- package/src/internal/openapi/search.test.ts +62 -0
- package/src/internal/openapi/search.ts +108 -0
- package/src/internal/openapi/sidebar.test.ts +131 -0
- package/src/internal/openapi/sidebar.ts +94 -0
- package/src/internal/openapi/union.test.ts +51 -0
- package/src/internal/openapi/union.ts +74 -0
- package/src/internal/search.ts +20 -0
- package/src/internal/test/virtual-config.stub.ts +14 -0
- package/src/internal/vite-plugins.ts +106 -11
- package/src/openapi-app/App.tsx +64 -0
- package/src/openapi-app/blocks.tsx +33 -0
- package/src/openapi-app/client.tsx +25 -0
- package/src/openapi-app/links.test.ts +84 -0
- package/src/openapi-app/links.ts +66 -0
- package/src/openapi-app/payload.ts +20 -0
- package/src/openapi-app/virtual/config.ts +7 -0
- package/src/openapi-app/virtual/group-icons.ts +2 -0
- package/src/openapi-app/virtual/langs.ts +6 -0
- package/src/openapi-app/virtual/openapi.ts +10 -0
- package/src/openapi-app/virtual/search-index.ts +21 -0
- package/src/openapi-app/virtual/slots.ts +4 -0
- package/src/openapi-app/virtual/user-styles.ts +2 -0
- package/src/openapi-app/waku.tsx +154 -0
- package/src/react/Badge.tsx +2 -3
- package/src/react/Layout.client.tsx +17 -4
- package/src/react/OpenApi.tsx +5 -0
- package/src/react/internal/CodeToHtml.client.tsx +283 -22
- package/src/react/internal/Sidebar.tsx +126 -22
- package/src/react/internal/openapi/CodeSample.client.tsx +294 -0
- package/src/react/internal/openapi/CollapsibleChildren.client.tsx +41 -0
- package/src/react/internal/openapi/Disclosure.client.tsx +67 -0
- package/src/react/internal/openapi/Endpoints.tsx +58 -0
- package/src/react/internal/openapi/EndpointsView.tsx +76 -0
- package/src/react/internal/openapi/EnumValues.client.tsx +49 -0
- package/src/react/internal/openapi/HeadingAnchor.tsx +28 -0
- package/src/react/internal/openapi/OpenApiPage.tsx +173 -0
- package/src/react/internal/openapi/Operation.test.tsx +101 -0
- package/src/react/internal/openapi/Operation.tsx +335 -0
- package/src/react/internal/openapi/Playground.client.tsx +234 -0
- package/src/react/internal/openapi/PropertyExample.client.tsx +55 -0
- package/src/react/internal/openapi/Reference.tsx +120 -0
- package/src/react/internal/openapi/Schema.tsx +467 -0
- package/src/react/internal/openapi/SchemaUnion.client.tsx +123 -0
- package/src/react/internal/openapi/anchor-navigation.client.ts +154 -0
- package/src/react/internal/openapi/auth.ts +69 -0
- package/src/react/useLayout.ts +4 -0
- package/src/server/handlers.ts +31 -6
- package/src/server/og-assets.ts +14 -0
- package/src/server/openapi/assets.generated.ts +1093 -0
- package/src/server/openapi/assets.ts +57 -0
- package/src/server/openapi/handler.test.ts +244 -0
- package/src/server/openapi/handler.ts +277 -0
- package/src/server/openapi/html.ts +84 -0
- package/src/server/openapi/pages.test.ts +111 -0
- package/src/server/openapi/pages.ts +153 -0
- package/src/server/openapi/state.ts +136 -0
- package/src/styles/index.css +16 -0
- package/src/styles/markdown.css +9 -7
- package/src/styles/openapi-playground.css +80 -0
- package/src/styles/openapi.css +660 -0
- package/src/vite.ts +1 -0
- package/src/waku/internal/middleware/md-router.ts +8 -52
- package/src/waku/internal/patches/adapters/vercel-build-enhancer.ts +1 -1
- 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, '&')
|
|
67
|
+
.replace(/</g, '<')
|
|
68
|
+
.replace(/>/g, '>')
|
|
69
|
+
.replace(/"/g, '"')
|
|
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, '&').replace(/"/g, '"')
|
|
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
|
+
}
|