| 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 | */ |
| 12 | import * as Effect from "../Effect.ts" |
| 13 | import * as Function from "../Function.ts" |
| 14 | import * as HttpRouter from "../http/HttpRouter.ts" |
| 15 | import * as HttpServerResponse from "../http/HttpServerResponse.ts" |
| 16 | import type * as Layer from "../Layer.ts" |
| 17 | import type * as HttpApi from "./HttpApi.ts" |
| 18 | import type * as HttpApiGroup from "./HttpApiGroup.ts" |
| 19 | import * as Html from "./internal/html.ts" |
| 20 | import * as internal from "./internal/httpApiScalar.ts" |
| 21 | import * 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 | */ |
| 30 | export 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 | */ |
| 56 | export 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 | |
| 136 | type ScalarSource = |
| 137 | | { |
| 138 | readonly _tag: "Cdn" |
| 139 | readonly version?: string | undefined |
| 140 | } |
| 141 | | { |
| 142 | readonly _tag: "Inline" |
| 143 | readonly source: string |
| 144 | } |
| 145 | |
| 146 | const 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 | */ |
| 216 | export 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 | */ |
| 248 | export 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 | })) |