1/**
2 * Scalar documentation UI for declarative `HttpApi` contracts.
3 *
4 * Use this module to mount a browser-based API reference on an `HttpRouter`
5 * without writing or storing a separate OpenAPI file. The route renders an HTML
6 * page containing the OpenAPI document produced from the supplied `HttpApi` and
7 * boots Scalar in the browser.
8 *
9 * @stability unstable
10 * @since 4.0.0
11 */
12import * as Effect from "../Effect.ts"
13import * as Function from "../Function.ts"
14import * as HttpRouter from "../http/HttpRouter.ts"
15import * as HttpServerResponse from "../http/HttpServerResponse.ts"
16import type * as Layer from "../Layer.ts"
17import type * as HttpApi from "./HttpApi.ts"
18import type * as HttpApiGroup from "./HttpApiGroup.ts"
19import * as Html from "./internal/html.ts"
20import * as internal from "./internal/httpApiScalar.ts"
21import * as OpenApi from "./OpenApi.ts"
22
23/**
24 * Theme preset identifier accepted by the Scalar API reference UI.
25 *
26 * @stability unstable
27 * @category models
28 * @since 4.0.0
29 */
30export type ScalarThemeId =
31 | "alternate"
32 | "default"
33 | "moon"
34 | "purple"
35 | "solarized"
36 | "bluePlanet"
37 | "saturn"
38 | "kepler"
39 | "mars"
40 | "deepSpace"
41 | "laserwave"
42 | "none"
43
44/**
45 * Configuration passed to the embedded Scalar API reference UI.
46 *
47 * **Details**
48 *
49 * This configuration follows Scalar's API reference configuration:
50 * https://github.com/scalar/scalar/blob/main/documentation/configuration.md
51 *
52 * @stability unstable
53 * @category models
54 * @since 4.0.0
55 */
56export type ScalarConfig = {
57 /** A string to use one of the color presets */
58 theme?: ScalarThemeId
59 /** The layout to use for the references */
60 layout?: "modern" | "classic"
61 /** URL to a request proxy for the API client */
62 proxyUrl?: string
63 /** Browser JavaScript function expression used by Scalar for documents and test requests */
64 customFetch?: string
65 /** Whether to show the sidebar */
66 showSidebar?: boolean
67 /**
68 * Whether to show models in the sidebar, search, and content.
69 *
70 * @default false
71 */
72 hideModels?: boolean
73 /**
74 * Whether to show the "Test Request" button.
75 *
76 * @default false
77 */
78 hideTestRequestButton?: boolean
79 /**
80 * Whether to show the sidebar search bar.
81 *
82 * @default false
83 */
84 hideSearch?: boolean
85 /** Whether dark mode is on or off initially (light mode) */
86 darkMode?: boolean
87 /** forceDarkModeState makes it always this state no matter what */
88 forceDarkModeState?: "dark" | "light"
89 /** Whether to show the dark mode toggle */
90 hideDarkModeToggle?: boolean
91 /**
92 * Path to a favicon image.
93 *
94 * @default undefined
95 */
96 favicon?: string
97 /** Custom CSS to be added to the page */
98 customCss?: string
99 /**
100 * Origin used when the OpenAPI document contains relative server URLs and is
101 * rendered during SSR.
102 *
103 * **Details**
104 *
105 * Browsers can derive the origin from `window.location.origin`; server
106 * rendering needs this value supplied explicitly.
107 *
108 * @default undefined
109 */
110 baseServerURL?: string
111 /**
112 * Whether Scalar loads its default Inter and JetBrains Mono fonts.
113 *
114 * **Details**
115 *
116 * Set this to `false` when supplying custom fonts.
117 *
118 * @default true
119 */
120 withDefaultFonts?: boolean
121 /**
122 * Whether all tags are open by default instead of only the tag matching the
123 * current URL.
124 *
125 * @default false
126 */
127 defaultOpenAllTags?: boolean
128 /**
129 * Whether to display the operation ID in the operation reference.
130 *
131 * @default false
132 */
133 showOperationId?: boolean
134}
135
136type ScalarSource =
137 | {
138 readonly _tag: "Cdn"
139 readonly version?: string | undefined
140 }
141 | {
142 readonly _tag: "Inline"
143 readonly source: string
144 }
145
146const makeHandler = <Id extends string, Groups extends HttpApiGroup.Constraint>(options: {
147 readonly api: HttpApi.HttpApi<Id, Groups>
148 readonly source: ScalarSource
149 readonly scalar: ScalarConfig | undefined
150}) => {
151 const makeResponse = Function.memoize((api: HttpApi.HttpApi<Id, Groups>) => {
152 const spec = OpenApi.fromApi(api)
153 const { customFetch, ...scalar } = options.scalar ?? {}
154 const scalarConfig = {
155 _integration: "html",
156 ...scalar
157 }
158 const scalarScript = options.source._tag === "Cdn"
159 ? `<script src="${
160 Html.escapeAttribute(
161 `https://cdn.jsdelivr.net/npm/@scalar/api-reference@${
162 encodeURIComponent(options.source.version ?? "latest")
163 }/dist/browser/standalone.min.js`
164 )
165 }" crossorigin></script>`
166 : `<script>${options.source.source}</script>`
167 return HttpServerResponse.html(`<!doctype html>
168<html>
169 <head>
170 <meta charset="utf-8" />
171 <title>${Html.escape(spec.info.title)}</title>
172 ${
173 !spec.info.description
174 ? ""
175 : `<meta name="description" content="${Html.escapeAttribute(spec.info.description)}"/>`
176 }
177 ${
178 !spec.info.description
179 ? ""
180 : `<meta name="og:description" content="${Html.escapeAttribute(spec.info.description)}"/>`
181 }
182 <meta
183 name="viewport"
184 content="width=device-width, initial-scale=1" />
185 </head>
186 <body>
187 <div id="api-reference-container"></div>
188 ${scalarScript}
189 <script>
190 window.Scalar.createApiReference(document.getElementById('api-reference-container'), {
191 ...${Html.escapeJson(scalarConfig)},
192 content: ${Html.escapeJson(spec)}${
193 customFetch === undefined ? "" : `,
194 customFetch: ${customFetch}`
195 }
196 })
197 </script>
198 </body>
199</html>`)
200 })
201 return Effect.sync(() => makeResponse(options.api))
202}
203
204/**
205 * Mounts a Scalar API reference page for an `HttpApi` using the bundled Scalar script.
206 *
207 * **Details**
208 *
209 * The route serves the OpenAPI specification generated from the API at the
210 * configured path, defaulting to `/docs`.
211 *
212 * @stability unstable
213 * @category layers
214 * @since 4.0.0
215 */
216export const layer = <Id extends string, Groups extends HttpApiGroup.Constraint>(
217 api: HttpApi.HttpApi<Id, Groups>,
218 options?: {
219 readonly path?: `/${string}` | undefined
220 readonly scalar?: ScalarConfig
221 } | undefined
222): Layer.Layer<never, never, HttpRouter.HttpRouter> =>
223 HttpRouter.use(Effect.fnUntraced(function*(router) {
224 const handler = makeHandler({
225 api,
226 source: {
227 _tag: "Inline",
228 source: internal.javascript
229 },
230 scalar: options?.scalar
231 })
232 yield* router.add("GET", options?.path ?? "/docs", handler)
233 }))
234
235/**
236 * Mounts a Scalar API reference page for an `HttpApi` that loads Scalar from jsDelivr.
237 *
238 * **Details**
239 *
240 * The route serves the OpenAPI specification generated from the API at the
241 * configured path, defaulting to `/docs`; `version` selects the Scalar package
242 * version loaded from the CDN.
243 *
244 * @stability unstable
245 * @category layers
246 * @since 4.0.0
247 */
248export const layerCdn = <Id extends string, Groups extends HttpApiGroup.Constraint>(
249 api: HttpApi.HttpApi<Id, Groups>,
250 options?: {
251 readonly path?: `/${string}` | undefined
252 readonly scalar?: ScalarConfig
253 readonly version?: string | undefined
254 } | undefined
255): Layer.Layer<never, never, HttpRouter.HttpRouter> =>
256 HttpRouter.use(Effect.fnUntraced(function*(router) {
257 const handler = makeHandler({
258 api,
259 source: {
260 _tag: "Cdn",
261 version: options?.version
262 },
263 scalar: options?.scalar
264 })
265 yield* router.add("GET", options?.path ?? "/docs", handler)
266 }))